Compare commits

...
Author SHA1 Message Date
Aiden Cline f484949568 fix(core): restore shell tool fallback 2026-08-21 00:49:59 -05: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
Aiden Cline 212139ff95 fix(ai): accept nullable Anthropic input usage (#43761) 2026-08-20 22:27:16 -05:00
opencode-agent[bot] e5eabc446d chore: update nix node_modules hashes 2026-08-21 02:48:42 +00:00
opencode-agent[bot]andAiden Cline ea43a16b7d refactor(core): remove legacy Gemini system prompt (#43752)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-20 21:48:06 -05:00
960b1ca284 refactor(desktop): move IPC to Effect RPC (#43207)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-21 02:30:27 +00:00
Kit Langton 4db272ff64 fix(core): steer manual compaction by default (#43724) 2026-08-21 01:51:36 +00:00
opencode-agent[bot]andrekram1-node fdc4fd8268 refactor(core): remove dead AI SDK ID stripping (#43741)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-20 20:44:53 -05:00
Luke Parker 67e87f534e fix(app): speed up cold home navigation (#43738) 2026-08-21 00:56:34 +00:00
Aiden Cline 2938ac3298 fix(core): prevent shell eviction loop (#43650) 2026-08-20 19:25:06 -05:00
opencode-agent[bot]andHona 749d24ebc0 fix(client): authenticate PTY websocket connections (#43735)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-21 00:02:54 +00:00
Luke Parker 384cff3768 refactor(app): establish session vertical slice (#43637) 2026-08-21 09:16:07 +10:00
Aiden Cline 5970537a8a feat(plugin): expose session selection methods (#43718) 2026-08-20 18:04:43 -05:00
Simon Klee 58f909d5b9 chore: upgrade opentui 0.5.6 (#43725) 2026-08-20 22:13:12 +00:00
Dax cc15c2a488 feat(posts): add Astro posts site (#43720) 2026-08-20 16:37:28 -04:00
Kit Langton ebc2504ef3 refactor(core): route title and compaction through shared model requests (#43555) 2026-08-20 16:46:57 +00:00
Aiden Cline c33c9bf2b9 fix(core): preserve background shell status (#43663) 2026-08-20 11:01:15 -05:00
Kit Langton 2970b7a6a8 fix(core): settle foreign typed tool failures instead of dropping them (#43576) 2026-08-20 11:56:35 -04:00
Kit Langton 82d2c6133e fix(tui): stop registering one resize listener per transcript row (#43562) 2026-08-20 11:55:37 -04:00
Dax Raad 22f2604ffa fix(core): preserve unicode in compaction truncation 2026-08-20 11:52:42 -04:00
Shoubhit Dash a71884dfdf test(ai): record responses websocket flows (#43660) 2026-08-20 21:14:37 +05:30
Kit Langton 6f629c2a9d refactor(core): use Latch for shell output gate (#43573) 2026-08-20 11:38:01 -04:00
Kit Langton 4651bd15de refactor(server): use Latch for shutdown gate (#43571) 2026-08-20 11:37:35 -04:00
Kit Langton ad7ebe84a0 refactor(tui): use Latch for renderer shutdown gate (#43572) 2026-08-20 11:37:22 -04:00
Kit Langton 38eeed56cd refactor(core): use Latch for MCP startup gate (#43570) 2026-08-20 11:37:19 -04:00
Kit Langton b453c2016c refactor(core): use Latch for plugin supervisor ready gate (#43569) 2026-08-20 11:37:14 -04:00
449 changed files with 13813 additions and 14473 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
+37
View File
@@ -0,0 +1,37 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1113 -573
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JEqi00PCle+o5OfBlJJaZtXd+4sYB3o+rvYiESlN4dY=",
"aarch64-linux": "sha256-zk3Uk1SQyeRrQ7BuFwlOnQAptUHIkq+oPdfd+sTEq5U=",
"aarch64-darwin": "sha256-3BOd3EcqimoG3rTI6lTHe91YVlCoEi8/68eT1lbOi0c=",
"x86_64-darwin": "sha256-X7wGmjiMloF5Zhuc20kAxLC+tl613YNXRgA+dQjP2WM="
"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="
}
}
+3 -3
View File
@@ -48,9 +48,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -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"
@@ -234,7 +246,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
@@ -692,7 +704,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
@@ -1039,7 +1051,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" }),
})
+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),
)
@@ -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"
@@ -57,7 +56,7 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: Framing.sse,
framing: AnthropicMessages.framing,
})
export const routes = [route]
+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"
@@ -0,0 +1,100 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -0,0 +1,119 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -0,0 +1,129 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
+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,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") })
@@ -640,7 +640,60 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
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(
Effect.provide(
@@ -663,6 +716,7 @@ describe("Anthropic Messages route", () => {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: null,
output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" },
@@ -682,7 +736,7 @@ describe("Anthropic Messages route", () => {
totalTokens: 15,
providerMetadata: {
anthropic: {
input_tokens: 5,
input_tokens: null,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
@@ -0,0 +1,216 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
+10 -2
View File
@@ -1,5 +1,7 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -16,7 +18,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll(
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
})
@@ -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)
@@ -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)
@@ -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)
@@ -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" } })
@@ -76,7 +76,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
})
})
await page.route("**/pty*", (route) =>
await page.route(/\/api\/pty(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -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)
@@ -1,7 +1,7 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
import { observeElementOffsetReconnectAware } from "../../../src/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40
@@ -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"]')
+10
View File
@@ -22,6 +22,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[])
@@ -383,6 +384,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const revertStage = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/)?.[1]
if (revertStage && route.request().method() === "POST") {
const body = route.request().postDataJSON()
if (!body || typeof body !== "object" || !("messageID" in body) || typeof body.messageID !== "string") {
return json(route, { error: "Invalid revert request" }, undefined, 400)
}
config.onRevertStage?.({ sessionID: revertStage, messageID: body.messageID })
return json(route, { data: { messageID: body.messageID } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
+1 -1
View File
@@ -36,7 +36,7 @@ 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("@/pages/session"), 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 })),
@@ -12,8 +12,8 @@ import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = {
@@ -1,14 +1,14 @@
.command-palette-v2 {
.command-palette {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
[data-component="dialog-v2"]:has(.command-palette) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
[data-component="dialog-v2"]:has(.command-palette) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
@@ -19,7 +19,7 @@
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
.command-palette-body {
display: flex;
min-height: 0;
flex: 1;
@@ -28,12 +28,12 @@
padding: 0;
}
.command-palette-v2-search {
.command-palette-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
.command-palette-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
@@ -45,46 +45,46 @@
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
.command-palette-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
.command-palette-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
.command-palette-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
.command-palette-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
.command-palette-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
@@ -95,7 +95,7 @@
user-select: none;
}
.command-palette-v2-row {
.command-palette-row {
display: flex;
width: 100%;
height: 36px;
@@ -113,16 +113,16 @@
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
.command-palette-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
.command-palette-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
.command-palette-row-main {
display: flex;
min-width: 0;
flex: 1;
@@ -130,19 +130,19 @@
gap: 8px;
}
.command-palette-v2-row-icon {
.command-palette-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
.command-palette-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
.command-palette-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
@@ -154,8 +154,8 @@
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
.command-palette-description,
.command-palette-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
@@ -167,11 +167,11 @@
white-space: nowrap;
}
.command-palette-v2-meta {
.command-palette-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
.command-palette-file-path {
display: flex;
min-width: 0;
align-items: baseline;
@@ -180,7 +180,7 @@
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
.command-palette-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
@@ -189,14 +189,14 @@
white-space: nowrap;
}
.command-palette-v2-file-name {
.command-palette-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
.command-palette-state {
display: grid;
min-height: 120px;
place-items: center;
@@ -208,13 +208,13 @@
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
.command-palette-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
.command-palette-description {
max-width: 100%;
}
}
@@ -22,7 +22,7 @@ import {
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
import "./dialog-command-palette.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
@@ -35,7 +35,7 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const loadItems = async (text: string) => {
const q = text.trim()
@@ -61,7 +61,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
)
}
export function DialogHomeCommandPaletteV2(props: {
export function DialogHomeCommandPalette(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
@@ -188,9 +188,9 @@ function CommandPaletteView(props: {
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<Dialog class="command-palette" size="large">
<DialogBody class="command-palette-body">
<div class="command-palette-search">
<TextInput
value={query()}
autofocus
@@ -203,21 +203,21 @@ function CommandPaletteView(props: {
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
<div class="command-palette-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<div class="command-palette-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
<div class="command-palette-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
@@ -262,7 +262,7 @@ function PaletteRow(props: {
return (
<button
type="button"
class="command-palette-v2-row group"
class="command-palette-row group"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
@@ -276,21 +276,21 @@ function PaletteRow(props: {
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
<div class="command-palette-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-row-icon size-4" />
<div class="command-palette-file-path">
<span class="command-palette-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<div class="command-palette-row-main">
<div class="command-palette-row-text">
<span class="command-palette-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
<span class="command-palette-description">{props.item.description}</span>
</Show>
</div>
</div>
@@ -299,7 +299,7 @@ function PaletteRow(props: {
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-row-main">
<div class="relative shrink-0">
<Show when={props.sessionOpen}>
<span
@@ -319,19 +319,19 @@ function PaletteRow(props: {
)}
</Show>
</div>
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
<div class="command-palette-row-text">
<span class="command-palette-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
<span class="command-palette-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
<span class="command-palette-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
+2 -2
View File
@@ -1,7 +1,7 @@
import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useData } from "@/context/server"
import { usePrompt } from "@/context/prompt"
import { useComposerState } from "@/composer/persistence"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
@@ -30,7 +30,7 @@ export const DialogFork: Component = () => {
const data = useData()
const serverSDK = useServerSDK()
const location = useWorkspaceLocation()
const prompt = usePrompt()
const prompt = useComposerState()
const dialog = useDialog()
const language = useLanguage()
const server = useServer()
@@ -2,11 +2,9 @@ import { Button } from "@opencode-ai/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { useLocal } from "@/context/local"
@@ -27,104 +25,6 @@ export const DialogManageModels: Component = () => {
const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
const setProviderVisibility = (providerID: string, checked: boolean) => {
providerList(providerID).forEach((x) => {
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
})
}
return (
<Dialog>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")}
/>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}>
{language.t("command.provider.connect")}
</Button>
</DialogHeader>
<DialogBody>
<List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`}
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
appearance="standard"
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
<Switch
appearance="standard"
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
appearance="standard"
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
/>
</div>
</div>
)}
</List>
</DialogBody>
</Dialog>
)
}
export const DialogManageModelsV2: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
}
@@ -1,177 +0,0 @@
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog"
import { Badge } from "@opencode-ai/ui/badge"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useIntegrations } from "@/hooks/use-integrations"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const integrations = useIntegrations(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
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 selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
dialog.close()
}
// Focus starts on the dialog's close button, outside the list, so listen at the
// document level while the dialog is mounted instead of on the list container.
let listEl: HTMLDivElement | undefined
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
if (!listEl) return
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
if (buttons.length === 0) return
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next =
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
buttons[(next + buttons.length) % buttons.length]?.focus()
e.preventDefault()
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
return (
<Dialog
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col">
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
</div>
<For each={freeModels()}>
{(item) => (
<Tooltip
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
v2
/>
}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</Tooltip>
)}
</For>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={integrations
.list()
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
theme.mode() !== "dark",
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
}}
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
</Show>
</span>
</button>
)}
</For>
<button
type="button"
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
{language.t("dialog.model.unpaid.viewMoreProviders")}
</button>
</div>
</div>
</div>
</div>
</DialogBody>
</Dialog>
)
}
@@ -2,7 +2,7 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createSignal, onMount } from "solid-js"
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
import { DialogSelectModelUnpaid } from "./dialog-select-model-unpaid"
const names = [
"MiMo V2.5 Free",
@@ -34,7 +34,7 @@ function SelectModelWithoutProviders() {
setCurrent(models.find((item) => item.id === value?.modelID))
},
}
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
const open = () => dialog.show(() => <DialogSelectModelUnpaid model={model} />)
onMount(open)
@@ -1,26 +1,37 @@
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 { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog"
import { Badge } from "@opencode-ai/ui/badge"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, 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 { useIntegrations } from "@/hooks/use-integrations"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const integrations = useIntegrations(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
@@ -30,118 +41,132 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
})
}
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)
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
dialog.close()
}
// Focus starts on the dialog's close button, outside the list, so listen at the
// document level while the dialog is mounted instead of on the list container.
let listEl: HTMLDivElement | undefined
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
if (!listEl) return
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
if (buttons.length === 0) return
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next =
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
buttons[(next + buttons.length) % buttons.length]?.focus()
e.preventDefault()
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
return (
<Dialog class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none">
<DialogHeader>
<Dialog
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<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>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col">
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</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)
}}
</div>
<For each={freeModels()}>
{(item) => (
<Tooltip
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
v2
/>
}
>
{(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>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</Tooltip>
)}
</For>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={integrations
.list()
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
theme.mode() !== "dark",
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
}}
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
</Show>
</span>
</button>
)}
</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}
</For>
<button
type="button"
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
{language.t("dialog.provider.viewAll")}
</Button>
{language.t("dialog.model.unpaid.viewMoreProviders")}
</button>
</div>
</div>
</div>
@@ -113,113 +113,7 @@ const ModelList: Component<{
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
trigger: ModelSelectorTrigger
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
open: boolean
dismiss: Dismiss | null
}>({
open: false,
dismiss: null,
})
const dialog = useDialog()
const local = useLocal()
const directory = () => decode64(local.slug())
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
setStore("open", false)
}
const handleManage = () => {
close("manage")
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
const handleConnectProvider = () => {
close("provider")
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
})
}
const language = useLanguage()
return (
<Kobalte
open={store.open}
onOpenChange={(next) => {
if (next) setStore("dismiss", null)
setStore("open", next)
}}
modal={false}
placement="top-start"
gutter={4}
>
<Kobalte.Trigger as={props.trigger} />
<Kobalte.Portal>
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
close("escape")
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => close("outside")}
onFocusOutside={() => close("outside")}
onCloseAutoFocus={(event) => {
const dismiss = store.dismiss
if (dismiss === "outside") event.preventDefault()
if (dismiss === "escape" || dismiss === "select") {
event.preventDefault()
props.onClose?.(dismiss)
}
setStore("dismiss", null)
}}
>
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList
provider={props.provider}
model={props.model}
onSelect={() => close("select")}
class="p-1"
action={
<div class="flex items-center gap-1">
<Tooltip appearance="standard" placement="top" value={language.t("command.provider.connect")}>
<IconButton
icon={<Icon name="plus-small" />}
variant="ghost"
class="size-6"
aria-label={language.t("command.provider.connect")}
onClick={handleConnectProvider}
/>
</Tooltip>
<Tooltip appearance="standard" placement="top" value={language.t("dialog.model.manage")}>
<IconButton
icon={<Icon name="sliders" />}
variant="ghost"
class="size-6"
aria-label={language.t("dialog.model.manage")}
onClick={handleManage}
/>
</Tooltip>
</div>
}
/>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}
export function ModelSelectorPopoverV2(props: {
provider?: string
model?: ModelState
trigger: ModelSelectorTrigger
@@ -233,7 +127,7 @@ export function ModelSelectorPopoverV2(props: {
})
return (
<ModelSelectorPopoverV2View
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
@@ -241,7 +135,7 @@ export function ModelSelectorPopoverV2(props: {
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
void dialog.show(() => <module.DialogManageModels />)
})
}}
onClose={() => props.onClose?.()}
@@ -288,7 +182,7 @@ function createModelSelectorController(input: {
}
}
function ModelSelectorPopoverV2View(props: {
function ModelSelectorPopoverView(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
@@ -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 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { NewSessionView } from "./session-new-view"
@@ -1,95 +0,0 @@
import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { useWorkspaceLocation } from "@/context/location"
import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
const ROOT_CLASS = "size-full flex flex-col"
interface NewSessionViewProps {
worktree: string
}
export function NewSessionView(props: NewSessionViewProps) {
const sdk = useWorkspaceLocation()
const language = useLanguage()
const data = useData()
const project = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
return projectID ? data.project.get(projectID) : undefined
})
const sandboxes = createMemo(() => project()?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => project()?.canonical ?? sdk().directory)
const isWorktree = createMemo(() => {
const current = project()
if (!current) return false
return sdk().directory !== current.canonical
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return language.t("session.new.worktree.main")
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
return language.t("session.new.worktree.main")
}
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
return getFilename(value)
}
return (
<div class={ROOT_CLASS}>
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
<div class="flex flex-col items-center gap-6">
<Mark class="w-10" />
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
</div>
<div class="w-full flex flex-col gap-4 items-center">
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex items-start justify-center gap-1.5 min-h-5">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
{label(current())}
</div>
</div>
<Show when={project()}>
{(project) => (
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl())
.toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
</div>
</div>
</div>
)
}
@@ -1,4 +1,3 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Popover } from "@opencode-ai/ui/popover"
@@ -36,72 +35,6 @@ export function StatusPopover() {
lsp: [],
}),
)
return (
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-8 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
trigger={
<div class="relative size-4">
<div class="badge-mask-tight size-4 flex items-center justify-center">
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
})}`}
/>
</div>
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={4}
placement="bottom-end"
shift={-168}
>
<Show when={shown()}>
<Suspense
fallback={
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown()} />
</Suspense>
</Show>
</Popover>
)
}
export function StatusPopoverV2() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const data = useData()
const sdk = useWorkspaceLocation()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: (mcp() ?? []).map((item) => item.status.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: [],
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
+14 -27
View File
@@ -15,9 +15,7 @@ import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalConnectToken } from "@/utils/terminal-connect-token"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -179,7 +177,6 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory
const url = serverSDK.url
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -531,14 +528,6 @@ export const Terminal = (props: TerminalProps) => {
})
}
const connectToken = async () => {
const result = await terminalConnectToken({ url, id, directory })
if (result.ticket) return result.ticket
if (result.status === 404 || result.status === 405) return
if (result.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.status }))
}
const retry = (err: unknown) => {
if (disposed) return
if (reconn !== undefined) return
@@ -562,23 +551,21 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()
const ticket = await connectToken().catch((err) => {
fail(err)
return undefined
})
if (once.value) return
if (disposed) return
const socket = new WebSocket(
terminalWebSocketURL({
url,
id,
directory,
const socket = await serverSDK.pty
.connect({
ptyID: id,
location: { directory },
cursor: seek,
ticket,
}),
)
socket.binaryType = "arraybuffer"
})
.catch((err) => {
fail(err)
return undefined
})
if (!socket || once.value) return
if (disposed) {
socket.close(1000)
return
}
ws = socket
const handleOpen = () => {
@@ -14,7 +14,7 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { createTabComposerState } from "@/composer/persistence"
import { base64Encode } from "@opencode-ai/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
@@ -146,7 +146,7 @@ function SessionTabEntry(props: {
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
createTabComposerState(tabs, props.tab, current.scope, {
dir: base64Encode(value.location.directory),
id: value.id,
})
+3 -3
View File
@@ -31,7 +31,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
import { useGlobal } from "@/context/global"
import { ServerConnection } from "@/context/servers"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
@@ -240,7 +240,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
server: route.server,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
const model = tabs.stateValue<ComposerState>(sessionTab, "prompt")?.model.current()
void tabs.newDraft(
{ server: sessionTab.server, directory: activeSession.location.directory },
"",
@@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const activeTab = currentTab()
if (activeTab?.type !== "draft") return
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
const model = tabs.stateValue<ComposerState>(activeTab, "prompt")?.model.current()
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
+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 "@/context/local"
import type { ServerSDK } from "@/context/server-sdk"
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,
@@ -2,9 +2,9 @@ import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
const withCategory = (category: string) => {
return (option: Omit<CommandOption, "category">): CommandOption => ({
@@ -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,7 +34,7 @@ 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)
@@ -0,0 +1,474 @@
import { Show, createMemo, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import type { ModelSelection } from "@/context/local"
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 "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/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,8 +1,6 @@
import type { Prompt } from "@/context/prompt"
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export type PromptHistoryComment = {
@@ -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,5 +1,5 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/utils/persist"
import {
clonePromptHistoryComments,
@@ -7,9 +7,9 @@ import {
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 { 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 "@/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 { createSessionTabs } from "@/pages/session/helpers"
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 { formatServerError } from "@/utils/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())
}
@@ -3,30 +3,29 @@ 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 { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs, type Tab } from "@/context/tabs"
import type { ServerScope } from "@/utils/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: "@" })
}
@@ -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,7 +1,7 @@
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 type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
@@ -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,
}
}
+214
View File
@@ -0,0 +1,214 @@
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import type { ComposerControls } from "./adapter"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal, useServerCtx } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/context/local"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServers } from "@/context/servers"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs } from "@/context/tabs"
import { useProviders } from "@/hooks/use-providers"
import { useData } from "@/context/server"
import { normalizeAgentList } from "@/context/global-sync/utils"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { useComposerState } from "./persistence"
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 = useComposerState()
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const recent = () => models.recent.list().find(valid)
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, recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
}
const recentModels = createMemo(() =>
models.recent
.list()
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
current,
recent: recentModels,
list: models.list,
cycle(direction: 1 | -1) {
const items = recentModels()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
},
visible: models.visible,
setVisibility: models.setVisibility,
variant: {
configured() {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
},
selected() {
return prompt.model.current()?.variant
},
current() {
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
} satisfies ModelSelection
return selection
}
export function createComposerProjectControls(props: { draftId: string }) {
const server = useServers()
const serverSDK = useServerSDK()
const sdk = useWorkspaceLocation()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const projectServer = () => serverSDK.server
const projectServerCtx = useServerCtx(projectServer)
const projects = createMemo(() => {
if (server.list.length <= 1) {
return projectServerCtx().projects.list()
}
return server.list.flatMap((conn) => {
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
return global
.ensureServerCtx(conn)
.projects.list()
.map((project) => ({ ...project, server: item }))
})
})
const selectProject = (worktree: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(props.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
}
const addProject = (title: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (!conn) return
pickDirectory({
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory, serverKey)
},
})
}
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: sdk().directory,
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
}))
}
+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 "@/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"
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 "@/context/local"
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 "@/utils/draft-store"
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
}
@@ -20,7 +20,7 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
describe("Home V2 session index", () => {
test("loads all pages", async () => {
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
const calls: Array<{ cursor?: string }> = []
const calls: Array<{ cursor?: string; parentID: null }> = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
if (!input.cursor) return { data: first, cursor: { next: "next" } }
@@ -29,6 +29,7 @@ describe("Home V2 session index", () => {
expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
expect(calls.every((call) => call.parentID === null)).toBe(true)
})
test("keeps only visible roots", () => {
@@ -6,7 +6,12 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
input: {
limit: number
order: "desc"
parentID: null
cursor?: string
},
options: { signal?: AbortSignal },
) => Promise<SessionsResponse>,
signal?: AbortSignal,
@@ -19,6 +24,7 @@ export async function loadHomeSessionIndex(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
parentID: null,
...(cursor ? { cursor } : {}),
},
{ signal },
@@ -29,8 +35,7 @@ export async function loadHomeSessionIndex(
}
}
// The V2 API cannot yet filter roots, archives, or several directories, so Home
// seeds createData from a full scan and derives its visible index there.
// Keep this filter for locally known sessions merged into the fetched index.
export function parseHomeSessionIndex(sessions: SessionInfo[]) {
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
}
@@ -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),
}
}
+4 -1
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
@@ -61,6 +61,7 @@ type ServerSDKBase = {
scope: ServerScope
url: string
api: ServerApi
pty: ReturnType<typeof createPtyClient>
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
@@ -72,6 +73,7 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const pty = createPtyClient(api, { url: server.http.url })
const events = createOpenCodeEventSource()
const connection = createClientConnection(api, {
@@ -93,6 +95,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope,
url: server.http.url,
api,
pty,
connection,
event: events.event,
}
-14
View File
@@ -26,7 +26,6 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
@@ -112,7 +111,6 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
showFileTree: false,
showNavigation: false,
showSearch: false,
@@ -176,11 +174,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})
return {
ready,
get current() {
@@ -195,13 +188,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
},
showFileTree,
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
+2 -2
View File
@@ -11,7 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { createDraftComposerState, type PromptModel } from "@/composer/state"
import { migrateTabs } from "./tab-migration"
import { useCurrentRoute } from "./layout"
@@ -210,7 +210,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
memory.ensure(tabKey(tab), "prompt", () => createDraftComposerState(draftID, { prompt, model }))
await startTransition(() => {
setStore(
produce((tabs) => {
+1 -35
View File
@@ -174,10 +174,6 @@ export const dict = {
"command.session.compact.description": "የአውድ መጠንን ለመቀነስ ክፍለ-ጊዜውን ያጠቃልሉት",
"command.session.fork": "ከመልዕክት አዲስ ቅርንጫፍ ፍጠር",
"command.session.fork.description": "ከቀደመው መልእክት አዲስ ክፍለ ጊዜ ፍጠር",
"command.session.share": "አጋራ ክፍለ ጊዜ",
"command.session.share.description": "ይህን ክፍለ ጊዜ ያጋሩ እና URLን ወደ ቅንጥብ ሰሌዳ ይቅዱ",
"command.session.unshare": "ክፍለ-ጊዜን አታጋራ",
"command.session.unshare.description": "ይህን ክፍለ ጊዜ ማጋራት አቁም",
"command.session.export": "ክፍለ ጊዜን ወደ ውጭ ላክ",
"command.session.export.description": "ሙሉውን የክፍለ ጊዜ ግልባጭ እንደ JSON",
"palette.search.placeholder": "ፋይሎችን፣ ትዕዛዞችን እና ክፍለ-ጊዜዎችን ይፈልጉ",
@@ -585,11 +581,6 @@ export const dict = {
"toast.file.listFailed.title": "ፋይሎችን መዘርዘር አልተሳካም",
"toast.context.noLineSelection.title": "የመስመር ምርጫ የለም",
"toast.context.noLineSelection.description": "በመጀመሪያ በፋይል ትር ውስጥ የመስመር ክልልን ይምረጡ።",
"toast.session.share.copyFailed.title": "URLን ወደ ቅንጥብ ሰሌዳ መቅዳት አልተሳካም",
"toast.session.share.success.title": "ክፍል የተጋራ",
"toast.session.share.success.description": "አጋራ URL ወደ ቅንጥብ ሰሌዳ ተቀድቷል!",
"toast.session.share.failed.title": "ክፍለ ጊዜን ማጋራት አልተሳካም",
"toast.session.share.failed.description": "ክፍለ-ጊዜውን በማጋራት ላይ ስህተት ተፈጥሯል",
"toast.session.unshare.success.title": "ክፍል ያልተጋራ",
"toast.session.unshare.success.description": "ክፍለ ጊዜው በተሳካ ሁኔታ አልተጋራም!",
"toast.session.unshare.failed.title": "ክፍለ ጊዜን አለማጋራት",
@@ -717,17 +708,6 @@ export const dict = {
"session.question.restore": "ጥያቄን ወደነበረበት መልስ",
"session.question.pending.one": "{{count}} በመጠባበቅ ላይ ያለ ጥያቄ",
"session.question.pending.other": "{{count}} በመጠባበቅ ላይ ያሉ ጥያቄዎች",
"session.followupDock.summary.one": "{{count}}የተሰለፈ መልእክት",
"session.followupDock.summary.other": "{{count}}የተሰለፉ መልዕክቶች",
"session.followupDock.sendNow": "አሁን ላክ",
"session.followupDock.edit": "አርትዕ",
"session.followupDock.collapse": "የተሰለፉ መልዕክቶችን ሰብስብ",
"session.followupDock.expand": "የተሰለፉ መልዕክቶችን ዘርጋ",
"session.revertDock.summary.one": "{{count}}የተመለሰ መልዕክት",
"session.revertDock.summary.other": "{{count}}የተመለሱ መልዕክቶች",
"session.revertDock.collapse": "የተመለሱ መልዕክቶችን ሰብስብ",
"session.revertDock.expand": "የተጠቀለሉ መልዕክቶችን ዘርጋ",
"session.revertDock.restore": "መልዕክት እነበረበት መልስ",
"session.new.title": "ማንኛውም ነገር ገንባ",
"session.new.project.new": "አዲስ ፕሮጀክት",
"session.new.project.search": "የፍለጋ ፕሮጀክቶች",
@@ -774,17 +754,7 @@ export const dict = {
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "ፕለጊኖች",
"status.popover.action.manageServers": "አገልጋዮችን አስተዳድር",
"session.share.popover.title": "በድር ላይ አትም",
"session.share.popover.description.shared": "ይህ ክፍለ ጊዜ በድር ላይ ይፋዊ ነው። አገናኙ ላለው ለማንኛውም ሰው ተደራሽ ነው።",
"session.share.popover.description.unshared": "ክፍለጊዜውን በይፋ በድሩ ላይ አጋራ። አገናኙ ላለው ለማንኛውም ሰው ተደራሽ ይሆናል።",
"session.share.action.share": "አጋራ",
"session.share.action.publish": "አትም",
"session.share.action.publishing": "በህትመት ላይ...",
"session.share.action.unpublish": "አትታተም",
"session.share.action.unpublishing": "ያለመታተም...",
"session.share.action.view": "እይታ",
"session.share.copy.copied": "የተገለበጠ",
"session.share.copy.copyLink": "መገልበጥ አገናኝ",
"common.copied": "የተገለበጠ",
"lsp.tooltip.none": "ምንም LSP አገልጋዮች",
"lsp.label.connected": "{{count}}LSP",
"prompt.loading": "ፕሮምፕትን በመጫን ላይ...",
@@ -909,10 +879,6 @@ export const dict = {
"settings.general.row.terminalFont.description": "በተርሚናል ውስጥ ጥቅም ላይ የዋለውን ቅርጸ-ቁምፊ አብጅ",
"settings.general.row.uiFont.title": "የUI ቅርጸ ቁምፊ",
"settings.general.row.uiFont.description": "በመገናኛው ሁሉ ጥቅም ላይ የዋለውን ቅርጸ-ቁምፊ አብጅ",
"settings.general.row.followup.title": "መከታተያ ባህሪ",
"settings.general.row.followup.description": "ክትትል የሚጠይቅ ከሆነ ወዲያውኑ ይመራ እንደሆነ ይምረጡ ወይም በሰልፍ ይጠብቁ",
"settings.general.row.followup.option.queue": "ወረፋ",
"settings.general.row.followup.option.steer": "መሪ",
"settings.general.row.showFileTree.title": "ፋይል ዛፍ",
"settings.general.row.showFileTree.description": "የፋይል ዛፍ ፓነልን በክፍሎች ውስጥ አሳይ",
"settings.general.row.showNavigation.title": "የአሰሳ መቆጣጠሪያዎች",
+1 -43
View File
@@ -180,10 +180,6 @@ export const dict = {
"command.session.compact.description": "تلخيص الجلسة لتقليل حجم السياق",
"command.session.fork": "تشعب من الرسالة",
"command.session.fork.description": "إنشاء جلسة جديدة من رسالة سابقة",
"command.session.share": "مشاركة الجلسة",
"command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة",
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"command.session.export": "تصدير الجلسة",
"command.session.export.description": "تصدير النص الكامل للجلسة بصيغة JSON",
@@ -589,11 +585,6 @@ export const dict = {
"toast.file.listFailed.title": "فشل سرد الملفات",
"toast.context.noLineSelection.title": "لا يوجد تحديد للأسطر",
"toast.context.noLineSelection.description": "حدد نطاق أسطر في تبويب ملف أولاً.",
"toast.session.share.copyFailed.title": "فشل نسخ عنوان URL إلى الحافظة",
"toast.session.share.success.title": "تمت مشاركة الجلسة",
"toast.session.share.success.description": "تم نسخ عنوان URL للمشاركة إلى الحافظة!",
"toast.session.share.failed.title": "فشل مشاركة الجلسة",
"toast.session.share.failed.description": "حدث خطأ أثناء مشاركة الجلسة",
"toast.session.unshare.success.title": "تم إلغاء مشاركة الجلسة",
"toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!",
"toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة",
@@ -712,25 +703,6 @@ export const dict = {
"session.question.pending.few": "{{count}} أسئلة معلقة",
"session.question.pending.many": "{{count}} سؤالًا معلقًا",
"session.question.pending.other": "الأسئلة المعلقة: {{count}}",
"session.followupDock.summary.one": "{{count}} رسالة في قائمة الانتظار",
"session.followupDock.summary.zero": "عدد الرسائل في قائمة الانتظار: {{count}}",
"session.followupDock.summary.two": "عدد الرسائل في قائمة الانتظار: {{count}}",
"session.followupDock.summary.few": "{{count}} رسائل في قائمة الانتظار",
"session.followupDock.summary.many": "{{count}} رسالةً في قائمة الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في قائمة الانتظار",
"session.followupDock.sendNow": "إرسال الآن",
"session.followupDock.edit": "تحرير",
"session.followupDock.collapse": "طي الرسائل المنتظرة",
"session.followupDock.expand": "توسيع الرسائل المنتظرة",
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
"session.revertDock.summary.zero": "عدد الرسائل التي تم التراجع عنها: {{count}}",
"session.revertDock.summary.two": "عدد الرسائل التي تم التراجع عنها: {{count}}",
"session.revertDock.summary.few": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.summary.many": "{{count}} رسالةً تم التراجع عنها",
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
"session.revertDock.restore": "استعادة الرسالة",
"session.new.title": "ابنِ أي شيء",
"session.new.project.new": "مشروع جديد",
"session.new.project.search": "البحث عن المشاريع",
@@ -758,17 +730,7 @@ export const dict = {
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "الإضافات",
"status.popover.action.manageServers": "إدارة الخوادم",
"session.share.popover.title": "نشر على الويب",
"session.share.popover.description.shared": "هذه الجلسة عامة على الويب. يمكن لأي شخص لديه الرابط الوصول إليها.",
"session.share.popover.description.unshared": "شارك الجلسة علنًا على الويب. ستكون متاحة لأي شخص لديه الرابط.",
"session.share.action.share": "مشاركة",
"session.share.action.publish": "نشر",
"session.share.action.publishing": "جارٍ النشر...",
"session.share.action.unpublish": "إلغاء النشر",
"session.share.action.unpublishing": "جارٍ إلغاء النشر...",
"session.share.action.view": "عرض",
"session.share.copy.copied": "تم النسخ",
"session.share.copy.copyLink": "نسخ الرابط",
"common.copied": "تم النسخ",
"lsp.tooltip.none": "لا توجد خوادم LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "جارٍ تحميل الموجه...",
@@ -846,10 +808,6 @@ export const dict = {
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
"settings.general.row.followup.option.queue": "قائمة انتظار",
"settings.general.row.followup.option.steer": "توجيه",
"settings.general.row.showFileTree.title": "شجرة الملفات",
"settings.general.row.showFileTree.description": "إظهار لوحة شجرة الملفات في الجلسات",
"settings.general.row.showNavigation.title": "عناصر التحكم في التنقل",
+1 -37
View File
@@ -176,10 +176,6 @@ export const dict = {
"command.session.compact.description": "Kontekst həcmini azaltmaq üçün sessiyanı xülasə et",
"command.session.fork": "Mesajdan fork et",
"command.session.fork.description": "Əvvəlki mesajdan yeni sessiya yarat",
"command.session.share": "Sessiyanı paylaş",
"command.session.share.description": "Bu sessiyanı paylaş və URL-ni buferə kopyala",
"command.session.unshare": "Sessiyanın paylaşımını dayandır",
"command.session.unshare.description": "Bu sessiyanın paylaşımını dayandır",
"command.session.export": "Sessiyanı ixrac et",
"command.session.export.description": "Sessiyanın tam transkriptini JSON formatında ixrac et",
@@ -599,11 +595,6 @@ export const dict = {
"toast.file.listFailed.title": "Fayllar siyahılana bilmədi",
"toast.context.noLineSelection.title": "Sətir seçimi yoxdur",
"toast.context.noLineSelection.description": "Əvvəlcə fayl tabında sətir aralığı seçin.",
"toast.session.share.copyFailed.title": "URL buferə kopyalana bilmədi",
"toast.session.share.success.title": "Sessiya paylaşıldı",
"toast.session.share.success.description": "Paylaşma URL-si buferə kopyalandı!",
"toast.session.share.failed.title": "Sessiya paylaşıla bilmədi",
"toast.session.share.failed.description": "Sessiyanı paylaşarkən xəta baş verdi",
"toast.session.unshare.success.title": "Sessiyanın paylaşımı dayandırıldı",
"toast.session.unshare.success.description": "Sessiyanın paylaşımı uğurla dayandırıldı!",
"toast.session.unshare.failed.title": "Sessiyanın paylaşımı dayandırıla bilmədi",
@@ -738,17 +729,6 @@ export const dict = {
"session.question.restore": "Sualı bərpa edin",
"session.question.pending.one": "{{count}} cavab gözləyən sual",
"session.question.pending.other": "{{count}} cavab gözləyən sual",
"session.followupDock.summary.one": "{{count}} növbəyə qoyulmuş mesaj",
"session.followupDock.summary.other": "{{count}} növbəyə qoyulmuş mesajlar",
"session.followupDock.sendNow": "İndi göndər",
"session.followupDock.edit": "Redaktə et",
"session.followupDock.collapse": "Növbəyə qoyulmuş mesajları yığcamlaşdırın",
"session.followupDock.expand": "Növbəyə qoyulmuş mesajları genişləndirin",
"session.revertDock.summary.one": "{{count}} geri alınmış mesaj",
"session.revertDock.summary.other": "{{count}} geri alınmış mesaj",
"session.revertDock.collapse": "Geri alınmış mesajları yığcamlaşdırın",
"session.revertDock.expand": "Geri qaytarılmış mesajları genişləndirin",
"session.revertDock.restore": "Mesajı bərpa edin",
"session.new.title": "İstədiyinizi qurun",
"session.new.project.new": "Yeni layihə",
"session.new.project.search": "Layihələri axtarın",
@@ -795,18 +775,7 @@ export const dict = {
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Plaginlər",
"status.popover.action.manageServers": "Serverləri idarə et",
"session.share.popover.title": "Vebdə dərc et",
"session.share.popover.description.shared": "Bu sessiya vebdə açıqdır. Linkə sahib olan hər kəs daxil ola bilər.",
"session.share.popover.description.unshared":
"Sessiyanı vebdə açıq paylaşın. Linkə sahib olan hər kəs daxil ola biləcək.",
"session.share.action.share": "Paylaş",
"session.share.action.publish": "Dərc et",
"session.share.action.publishing": "Dərc edilir...",
"session.share.action.unpublish": "Dərcdən çıxar",
"session.share.action.unpublishing": "Dərcdən çıxarılır...",
"session.share.action.view": "Bax",
"session.share.copy.copied": "Kopyalandı",
"session.share.copy.copyLink": "Linki kopyala",
"common.copied": "Kopyalandı",
"lsp.tooltip.none": "LSP server yoxdur",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Prompt yüklənir...",
@@ -936,11 +905,6 @@ export const dict = {
"settings.general.row.terminalFont.description": "Terminalda istifadə olunan şrifti fərdiləşdirin",
"settings.general.row.uiFont.title": "İnterfeys şrifti",
"settings.general.row.uiFont.description": "Bütün interfeysdə istifadə olunan şrifti fərdiləşdirin",
"settings.general.row.followup.title": "Sonrakı sorğuların davranışı",
"settings.general.row.followup.description":
"Sonrakı sorğuların dərhal yönləndirilməsini və ya növbədə gözləməsini seçin",
"settings.general.row.followup.option.queue": "Növbə",
"settings.general.row.followup.option.steer": "Yönləndir",
"settings.general.row.showFileTree.title": "Fayl ağacı",
"settings.general.row.showFileTree.description": "Seanslarda fayl ağacı panelini göstərin",
"settings.general.row.showNavigation.title": "Naviqasiya nəzarətləri",
+1 -37
View File
@@ -177,10 +177,6 @@ export const dict = {
"command.session.compact.description": "Обобщете сесията, за да намалите размера на контекста",
"command.session.fork": "Разклонение от съобщение",
"command.session.fork.description": "Създайте нова сесия от предишно съобщение",
"command.session.share": "Споделяне на сесия",
"command.session.share.description": "Споделете тази сесия и копирайте URL в клипборда",
"command.session.unshare": "Прекратяване на споделянето на сесията",
"command.session.unshare.description": "Спрете да споделяте тази сесия",
"command.session.export": "Експортиране на сесия",
"command.session.export.description": "Експортирайте пълния препис на сесията като JSON",
"palette.search.placeholder": "Търсене на файлове, команди и сесии",
@@ -597,11 +593,6 @@ export const dict = {
"toast.file.listFailed.title": "Неуспешно изброяване на файлове",
"toast.context.noLineSelection.title": "Няма избор на линия",
"toast.context.noLineSelection.description": "Първо изберете диапазон от редове в раздел на файл.",
"toast.session.share.copyFailed.title": "Неуспешно копиране на URL в клипборда",
"toast.session.share.success.title": "Сесията е споделена",
"toast.session.share.success.description": "Споделяне на URL копирано в клипборда!",
"toast.session.share.failed.title": "Неуспешно споделяне на сесията",
"toast.session.share.failed.description": "Възникна грешка при споделяне на сесията",
"toast.session.unshare.success.title": "Сесията е прекратена",
"toast.session.unshare.success.description": "Сесията бе прекратена успешно!",
"toast.session.unshare.failed.title": "Прекратяването на споделянето на сесията не бе успешно",
@@ -734,17 +725,6 @@ export const dict = {
"session.question.restore": "Възстановете въпроса",
"session.question.pending.one": "{{count}} чакащ въпрос",
"session.question.pending.other": "{{count}} висящи въпроси",
"session.followupDock.summary.one": "{{count}} съобщение в опашка",
"session.followupDock.summary.other": "{{count}} съобщения в опашка",
"session.followupDock.sendNow": "Изпратете сега",
"session.followupDock.edit": "Редактиране",
"session.followupDock.collapse": "Свиване на съобщенията в опашката",
"session.followupDock.expand": "Разгъване на съобщенията в опашката",
"session.revertDock.summary.one": "{{count}} върнато съобщение",
"session.revertDock.summary.other": "{{count}} отменени съобщения",
"session.revertDock.collapse": "Свиване на върнатите съобщения",
"session.revertDock.expand": "Разгъване на върнатите съобщения",
"session.revertDock.restore": "Възстановяване на съобщението",
"session.new.title": "Изградете каквото и да било",
"session.new.project.new": "Нов проект",
"session.new.project.search": "Търсене на проекти",
@@ -791,18 +771,7 @@ export const dict = {
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Плъгини",
"status.popover.action.manageServers": "Управление на сървъри",
"session.share.popover.title": "Публикувайте в мрежата",
"session.share.popover.description.shared": "Тази сесия е публична в мрежата. Достъпен е за всеки с връзката.",
"session.share.popover.description.unshared":
"Споделете сесията публично в мрежата. Тя ще бъде достъпна за всеки с връзката.",
"session.share.action.share": "Споделете",
"session.share.action.publish": "Публикувай",
"session.share.action.publishing": "Публикуване...",
"session.share.action.unpublish": "Отмяна на публикуването",
"session.share.action.unpublishing": "Отменя се публикуването...",
"session.share.action.view": "Преглед",
"session.share.copy.copied": "Копирано",
"session.share.copy.copyLink": "Копиране на връзката",
"common.copied": "Копирано",
"lsp.tooltip.none": "Няма LSP сървъри",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Подканата се зарежда...",
@@ -932,11 +901,6 @@ export const dict = {
"settings.general.row.terminalFont.description": "Персонализирайте шрифта, използван в терминала",
"settings.general.row.uiFont.title": "UI шрифт",
"settings.general.row.uiFont.description": "Персонализирайте шрифта, използван в целия интерфейс",
"settings.general.row.followup.title": "Последващо поведение",
"settings.general.row.followup.description":
"Изберете дали последващите подкани да се управляват незабавно или да чакат на опашка",
"settings.general.row.followup.option.queue": "Опашка",
"settings.general.row.followup.option.steer": "Насочвайте",
"settings.general.row.showFileTree.title": "Файлово дърво",
"settings.general.row.showFileTree.description": "Показване на панела на файловото дърво в сесии",
"settings.general.row.showNavigation.title": "Контроли за навигация",

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