mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 09:06:12 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5e3dac5b2 |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
|
||||
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
|
||||
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
|
||||
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
|
||||
"x86_64-linux": "sha256-JEqi00PCle+o5OfBlJJaZtXd+4sYB3o+rvYiESlN4dY=",
|
||||
"aarch64-linux": "sha256-zk3Uk1SQyeRrQ7BuFwlOnQAptUHIkq+oPdfd+sTEq5U=",
|
||||
"aarch64-darwin": "sha256-3BOd3EcqimoG3rTI6lTHe91YVlCoEi8/68eT1lbOi0c=",
|
||||
"x86_64-darwin": "sha256-X7wGmjiMloF5Zhuc20kAxLC+tl613YNXRgA+dQjP2WM="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,18 +33,6 @@ 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"
|
||||
@@ -246,7 +234,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
|
||||
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: optionalNull(Schema.Number),
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
@@ -704,7 +692,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 ?? undefined
|
||||
const nonCached = usage.input_tokens
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
@@ -1051,7 +1039,7 @@ export const route = Route.make({
|
||||
protocol,
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
framing,
|
||||
framing: Framing.sse,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -197,28 +197,19 @@ export const errorText = (error: unknown) => {
|
||||
|
||||
/**
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* 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
|
||||
* decoder, 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>,
|
||||
events?: ReadonlySet<string>,
|
||||
): Stream.Stream<string, AIError> =>
|
||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): 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) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -56,7 +57,7 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: AnthropicMessages.framing,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -24,10 +24,4 @@ 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"
|
||||
|
||||
@@ -10,9 +10,6 @@ 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, sseNamedEvent, sseRaw } from "../lib/sse.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
@@ -640,60 +640,7 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown named SSE events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseRaw(
|
||||
sseNamedEvent("message_start", {
|
||||
type: "message_start",
|
||||
message: { usage: { input_tokens: 5 } },
|
||||
}),
|
||||
sseNamedEvent("proxy.stats", "not json"),
|
||||
sseNamedEvent("content_block_start", {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
}),
|
||||
sseNamedEvent("content_block_delta", {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Hello" },
|
||||
}),
|
||||
sseNamedEvent("content_block_stop", { type: "content_block_stop", index: 0 }),
|
||||
sseNamedEvent("message_delta", {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { output_tokens: 1 },
|
||||
}),
|
||||
sseNamedEvent("message_stop", { type: "message_stop" }),
|
||||
sseNamedEvent("proxy.done", "still not json"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed recognized SSE events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseRaw(sseNamedEvent("message_start", "[DONE]")))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
|
||||
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
@@ -716,7 +663,6 @@ 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" },
|
||||
@@ -736,7 +682,7 @@ describe("Anthropic Messages route", () => {
|
||||
totalTokens: 15,
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
input_tokens: null,
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const contentSelector = '[data-message-id], [data-component="composer-editor"]'
|
||||
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
||||
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="composer-editor"]',
|
||||
destinationSelector: '[data-component="prompt-input"]',
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeVisible()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
|
||||
@@ -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="composer-editor"]'))
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
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)
|
||||
|
||||
+11
-11
@@ -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/ComposerEditing"
|
||||
const projectID = "proj_composer_editing"
|
||||
const sessionID = "ses_composer_editing"
|
||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||
const projectID = "proj_prompt_input_v2_editing"
|
||||
const sessionID = "ses_prompt_input_v2_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: "composer-editing",
|
||||
name: "prompt-input-v2-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: "composer-editing",
|
||||
slug: "prompt-input-v2-editing",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Composer editing",
|
||||
title: "Prompt input V2 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="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
await expect
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
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)}"`,
|
||||
)
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await input.fill("keep me")
|
||||
@@ -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 thinking level control while relevant", async ({ page }) => {
|
||||
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -51,8 +51,8 @@ test("shows the thinking level control while relevant", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
await expectAppVisible(composer)
|
||||
|
||||
|
||||
@@ -224,8 +224,6 @@ 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" } })
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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="composer-editor"][contenteditable="true"]')
|
||||
const editor = page.locator('[data-component="prompt-input"][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 read tools from their path input", async ({ page }) => {
|
||||
test("labels V2 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 read tools from their path input", async ({ page }) => {
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
|
||||
const pending = "prt_skill_id"
|
||||
const completed = "prt_skill_name"
|
||||
await setupTimeline(page, {
|
||||
@@ -131,10 +131,9 @@ test("labels 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")
|
||||
@@ -153,7 +152,8 @@ 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 event stream", async ({ page }) => {
|
||||
test("does not request replay when reconnecting the volatile V2 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="composer-editor"]')
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
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="composer-editor"]')
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
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="composer-editor"]')
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
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="composer-editor"]')
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
|
||||
@@ -78,9 +78,9 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
await selectFolder.click()
|
||||
|
||||
await page.locator('[data-action="home-new-session"]').click()
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
const modelControl = page.locator('[data-action="prompt-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"]')
|
||||
|
||||
@@ -22,7 +22,6 @@ 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[])
|
||||
@@ -384,15 +383,6 @@ 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": "*" } })
|
||||
}
|
||||
|
||||
+31
-31
@@ -1,14 +1,14 @@
|
||||
.command-palette {
|
||||
.command-palette-v2 {
|
||||
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) {
|
||||
[data-component="dialog-v2"]:has(.command-palette-v2) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"]:has(.command-palette) [data-slot="dialog-container"] {
|
||||
[data-component="dialog-v2"]:has(.command-palette-v2) [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-body {
|
||||
.command-palette-v2-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
@@ -28,12 +28,12 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.command-palette-search {
|
||||
.command-palette-v2-search {
|
||||
flex-shrink: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.command-palette-search [data-component="text-input-v2"] {
|
||||
.command-palette-v2-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-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]) {
|
||||
.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]) {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
|
||||
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
|
||||
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
|
||||
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.command-palette-scroll {
|
||||
.command-palette-v2-scroll {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.command-palette-results {
|
||||
.command-palette-v2-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 6px 6px 8px;
|
||||
}
|
||||
|
||||
.command-palette-group {
|
||||
.command-palette-v2-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.command-palette-group-title {
|
||||
.command-palette-v2-group-title {
|
||||
margin: 6px 0;
|
||||
padding: 0 12px;
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -95,7 +95,7 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.command-palette-row {
|
||||
.command-palette-v2-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
@@ -113,16 +113,16 @@
|
||||
scroll-margin: 6px 0;
|
||||
}
|
||||
|
||||
.command-palette-row[data-active] {
|
||||
.command-palette-v2-row[data-active] {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.command-palette-row:focus-visible {
|
||||
.command-palette-v2-row:focus-visible {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-palette-row-main {
|
||||
.command-palette-v2-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -130,19 +130,19 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-palette-row-icon {
|
||||
.command-palette-v2-row-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.command-palette-row-text {
|
||||
.command-palette-v2-row-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.command-palette-title {
|
||||
.command-palette-v2-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
@@ -154,8 +154,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-description,
|
||||
.command-palette-meta {
|
||||
.command-palette-v2-description,
|
||||
.command-palette-v2-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -167,11 +167,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-meta {
|
||||
.command-palette-v2-meta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.command-palette-file-path {
|
||||
.command-palette-v2-file-path {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
@@ -180,7 +180,7 @@
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
.command-palette-file-dir {
|
||||
.command-palette-v2-file-dir {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -189,14 +189,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-file-name {
|
||||
.command-palette-v2-file-name {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-state {
|
||||
.command-palette-v2-state {
|
||||
display: grid;
|
||||
min-height: 120px;
|
||||
place-items: center;
|
||||
@@ -208,13 +208,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.command-palette-row-text {
|
||||
.command-palette-v2-row-text {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.command-palette-description {
|
||||
.command-palette-v2-description {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
+26
-26
@@ -22,7 +22,7 @@ import {
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
import "./dialog-command-palette.css"
|
||||
import "./dialog-command-palette-v2.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 DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
|
||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const loadItems = async (text: string) => {
|
||||
const q = text.trim()
|
||||
@@ -61,7 +61,7 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogHomeCommandPalette(props: {
|
||||
export function DialogHomeCommandPaletteV2(props: {
|
||||
server: ServerConnection.Any
|
||||
onSelectSession: (entry: CommandPaletteEntry) => void
|
||||
}) {
|
||||
@@ -188,9 +188,9 @@ function CommandPaletteView(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog class="command-palette" size="large">
|
||||
<DialogBody class="command-palette-body">
|
||||
<div class="command-palette-search">
|
||||
<Dialog class="command-palette-v2" size="large">
|
||||
<DialogBody class="command-palette-v2-body">
|
||||
<div class="command-palette-v2-search">
|
||||
<TextInput
|
||||
value={query()}
|
||||
autofocus
|
||||
@@ -203,21 +203,21 @@ function CommandPaletteView(props: {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
|
||||
<div class="command-palette-results" role="listbox">
|
||||
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
|
||||
<div class="command-palette-v2-results" role="listbox">
|
||||
<Show
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-state">
|
||||
<div class="command-palette-v2-state">
|
||||
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groupedEntries()}>
|
||||
{(group) => (
|
||||
<div class="command-palette-group">
|
||||
<div class="command-palette-v2-group">
|
||||
<Show when={group.category}>
|
||||
<div class="command-palette-group-title">{group.category}</div>
|
||||
<div class="command-palette-v2-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-row group"
|
||||
class="command-palette-v2-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-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 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>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={props.item.type === "command"}>
|
||||
<div class="command-palette-row-main">
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{props.item.title}</span>
|
||||
<div class="command-palette-v2-row-main">
|
||||
<div class="command-palette-v2-row-text">
|
||||
<span class="command-palette-v2-title">{props.item.title}</span>
|
||||
<Show when={props.item.description}>
|
||||
<span class="command-palette-description">{props.item.description}</span>
|
||||
<span class="command-palette-v2-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-row-main">
|
||||
<div class="command-palette-v2-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-row-text">
|
||||
<span class="command-palette-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
<div class="command-palette-v2-row-text">
|
||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
{props.item.title}
|
||||
</span>
|
||||
<Show when={props.item.description}>
|
||||
<span class="command-palette-description" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
{props.item.description}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.item.updated}>
|
||||
<span class="command-palette-meta">
|
||||
<span class="command-palette-v2-meta">
|
||||
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
|
||||
</span>
|
||||
</Show>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useData } from "@/context/server"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
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 = useComposerState()
|
||||
const prompt = usePrompt()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
@@ -2,9 +2,11 @@ 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"
|
||||
@@ -25,6 +27,104 @@ 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()} />)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 { DialogSelectModelUnpaid } from "./dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
||||
|
||||
const names = [
|
||||
"MiMo V2.5 Free",
|
||||
@@ -34,7 +34,7 @@ function SelectModelWithoutProviders() {
|
||||
setCurrent(models.find((item) => item.id === value?.modelID))
|
||||
},
|
||||
}
|
||||
const open = () => dialog.show(() => <DialogSelectModelUnpaid model={model} />)
|
||||
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
||||
|
||||
onMount(open)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,26 @@
|
||||
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 { Button } from "@opencode-ai/ui/button"
|
||||
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 { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
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 integrations = useIntegrations(directory)
|
||||
const providers = useProviders(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) => {
|
||||
@@ -41,132 +30,118 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
})
|
||||
}
|
||||
|
||||
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
dialog.close()
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")}>
|
||||
<Dialog class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody 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")}
|
||||
<DialogBody>
|
||||
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||
<List
|
||||
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={model.list}
|
||||
current={model.current()}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
itemWrapper={(item, node) => (
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2.5">
|
||||
<span>{i.name}</span>
|
||||
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
|
||||
<Show when={i.latest}>
|
||||
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</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
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</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)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{(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>
|
||||
)}
|
||||
</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()}
|
||||
</List>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||
icon="dot-grid"
|
||||
onClick={all}
|
||||
>
|
||||
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||
</button>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,113 @@ 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
|
||||
@@ -127,7 +233,7 @@ export function ModelSelectorPopover(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<ModelSelectorPopoverView
|
||||
<ModelSelectorPopoverV2View
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
@@ -135,7 +241,7 @@ export function ModelSelectorPopover(props: {
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./dialog-manage-models").then((module) => {
|
||||
void dialog.show(() => <module.DialogManageModels />)
|
||||
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||
})
|
||||
}}
|
||||
onClose={() => props.onClose?.()}
|
||||
@@ -182,7 +288,7 @@ function createModelSelectorController(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function ModelSelectorPopoverView(props: {
|
||||
function ModelSelectorPopoverV2View(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
|
||||
+258
-116
@@ -1,33 +1,86 @@
|
||||
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 { createComponent, createEffect, createMemo, on } from "solid-js"
|
||||
import type { ComposerSuggestion } from "./types"
|
||||
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
|
||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
||||
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
||||
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
||||
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
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"
|
||||
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"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}
|
||||
|
||||
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
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 {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const files = useFile()
|
||||
@@ -35,20 +88,16 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const prompt = adapter.state
|
||||
const prompt = props.state ?? usePrompt()
|
||||
let editor: HTMLDivElement | undefined
|
||||
|
||||
const interaction = createComposerEditorState(prompt.mode.current())
|
||||
createEffect(
|
||||
on(adapter.ready, (ready) => {
|
||||
if (ready) interaction[1]("mode", prompt.mode.current())
|
||||
}),
|
||||
)
|
||||
const interaction = createPromptInputV2State()
|
||||
const mode = () => interaction[0].mode
|
||||
const history = createComposerHistory()
|
||||
const tabs = () => adapter.controls().session.tabs
|
||||
const history = props.history ?? createPersistedPromptInputHistory()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
@@ -64,6 +113,8 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
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"),
|
||||
)
|
||||
@@ -78,9 +129,20 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
.join("")
|
||||
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
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 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 historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
@@ -130,6 +192,39 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
)
|
||||
}
|
||||
|
||||
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(() =>
|
||||
@@ -178,26 +273,10 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
resource,
|
||||
})),
|
||||
)
|
||||
const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? [])
|
||||
const context = createMemo<ComposerSuggestion[]>(() => [
|
||||
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
||||
...references(),
|
||||
...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")
|
||||
...props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent) => ({
|
||||
id: `agent:${agent.name}`,
|
||||
kind: "agent" as const,
|
||||
@@ -232,7 +311,7 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const commands = createMemo<ComposerSuggestion[]>(() =>
|
||||
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
||||
slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
kind: "command",
|
||||
@@ -243,46 +322,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
)
|
||||
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,
|
||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||
const controller = createPromptInputV2Controller({
|
||||
store: () => prompt.capture().store,
|
||||
state: interaction,
|
||||
identity: () => prompt.capture(),
|
||||
history: {
|
||||
entries: (mode) =>
|
||||
history.entries(mode).map((value) => {
|
||||
@@ -307,14 +351,14 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, adapter.controls(), layout, files, comments)
|
||||
if (item) openComment(item, props, layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
if (adapter.kind === "active-session") adapter.setEditor(editor)
|
||||
props.ref?.(editor)
|
||||
},
|
||||
onSuggestionSelect(item) {
|
||||
if (item.kind !== "command") return
|
||||
@@ -343,35 +387,34 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
placeholder,
|
||||
placeholder: designPlaceholder,
|
||||
get agent() {
|
||||
const agents = adapter.controls().agents
|
||||
return agents.visible && agents.options.length > 0
|
||||
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||
? {
|
||||
options: () => adapter.controls().agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => adapter.controls().agents.current,
|
||||
onSelect: (value: string) => adapter.controls().agents.select(value),
|
||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => props.controls.agents.current,
|
||||
onSelect: (value: string) => props.controls.agents.select(value),
|
||||
keybind: () => command.keybindParts("agent.cycle"),
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
variant: {
|
||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||
current: () => adapter.controls().model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => adapter.controls().model.selection.variant.set(value === "default" ? undefined : value),
|
||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
||||
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||
},
|
||||
submit: {
|
||||
stopping,
|
||||
working: adapter.working,
|
||||
onSubmit: () => void submission.submit(new Event("submit")),
|
||||
onStop: () => void submission.stop(),
|
||||
working,
|
||||
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||
onStop: () => void submission.abort(),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(controller, "model", { get: () => adapter.controls().model })
|
||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||
|
||||
command.register("composer-editor", () => [
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
@@ -398,23 +441,122 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
},
|
||||
])
|
||||
|
||||
return controller as ComposerModel
|
||||
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
|
||||
}
|
||||
|
||||
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 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 openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
controls: ComposerControls,
|
||||
props: PromptInputV2ControllerProps,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
@@ -433,16 +575,16 @@ function openComment(
|
||||
})
|
||||
}
|
||||
const review = item.commentOrigin === "review"
|
||||
if (!controls.session.reviewPanel.opened()) controls.session.reviewPanel.open()
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
controls.session.tabs.setActive("review")
|
||||
props.controls.session.tabs.setActive("review")
|
||||
queueFocus()
|
||||
return
|
||||
}
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
void controls.session.tabs.open(tab)
|
||||
controls.session.tabs.setActive(tab)
|
||||
void props.controls.session.tabs.open(tab)
|
||||
props.controls.session.tabs.setActive(tab)
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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} />
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
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
|
||||
}
|
||||
+2
-29
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
test("builds text, files, and agents from the prompt", () => {
|
||||
@@ -319,30 +318,4 @@ 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
-9
@@ -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, SkillPart } from "@/composer/state"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
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,7 +10,6 @@ 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[]
|
||||
}
|
||||
|
||||
@@ -55,14 +54,8 @@ 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 {
|
||||
@@ -117,7 +110,6 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...images],
|
||||
agents,
|
||||
skills,
|
||||
comments,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
+62
-1
@@ -1,3 +1,32 @@
|
||||
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
|
||||
@@ -30,7 +59,9 @@ 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.mention
|
||||
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) {
|
||||
@@ -85,3 +116,33 @@ 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,98 @@
|
||||
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"
|
||||
}
|
||||
+13
-7
@@ -1,5 +1,5 @@
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
} from "./history"
|
||||
|
||||
export type ComposerHistoryStore = {
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
@@ -31,12 +31,12 @@ export function upgradeHistoryState(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function createComposerHistoryStore(
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): ComposerHistoryStore {
|
||||
): PromptInputHistory {
|
||||
return {
|
||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||
add(prompt, mode, comments) {
|
||||
@@ -49,7 +49,13 @@ function createComposerHistoryStore(
|
||||
}
|
||||
}
|
||||
|
||||
export function createComposerHistory() {
|
||||
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() {
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
@@ -58,7 +64,7 @@ export function createComposerHistory() {
|
||||
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
|
||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
return {
|
||||
...history,
|
||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
+120
-5
@@ -1,6 +1,8 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
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 = {
|
||||
@@ -20,12 +22,20 @@ 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,
|
||||
@@ -120,9 +130,6 @@ 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
|
||||
@@ -133,3 +140,111 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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...")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
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: "@" })
|
||||
}
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
|
||||
|
||||
export function createComposerSubmission(input: {
|
||||
target: ComposerStateTarget
|
||||
export function createPromptSubmissionState(input: {
|
||||
target: PromptTarget
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
@@ -20,11 +20,11 @@ export function createComposerSubmission(input: {
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: ComposerStateTarget) {
|
||||
input.context.forEach((item) => next.context.add(item))
|
||||
retarget(next: PromptTarget) {
|
||||
input.context.forEach(next.context.add)
|
||||
target = next
|
||||
},
|
||||
current: (value: ComposerStateTarget) => target === value,
|
||||
current: (value: PromptTarget) => target === value,
|
||||
restore() {
|
||||
if (cleared !== undefined && target.current() !== cleared) return
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
@@ -0,0 +1,480 @@
|
||||
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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,505 @@
|
||||
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,3 +1,4 @@
|
||||
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"
|
||||
@@ -35,6 +36,72 @@ 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,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 { createTabComposerState } from "@/composer/persistence"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
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
|
||||
createTabComposerState(tabs, props.tab, current.scope, {
|
||||
createTabPromptState(tabs, props.tab, current.scope, {
|
||||
dir: base64Encode(value.location.directory),
|
||||
id: value.id,
|
||||
})
|
||||
|
||||
@@ -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 { ComposerState } from "@/composer/persistence"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
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<ComposerState>(sessionTab, "prompt")?.model.current()
|
||||
const model = tabs.stateValue<PromptSession>(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<ComposerState>(activeTab, "prompt")?.model.current()
|
||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
@@ -1,43 +0,0 @@
|
||||
@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,474 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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,55 +0,0 @@
|
||||
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,3 +0,0 @@
|
||||
[data-component="composer-editor"]:empty::before {
|
||||
content: "\200B";
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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,15 +0,0 @@
|
||||
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/@")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
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,214 +0,0 @@
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,506 +0,0 @@
|
||||
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,364 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,323 +0,0 @@
|
||||
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,40 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,30 @@ 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 "@/context/servers"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useTabs, type Tab } from "@/context/tabs"
|
||||
import { ServerConnection } from "./servers"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
createComposerReady,
|
||||
createComposerState,
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
type ContextItem,
|
||||
type FileContextItem,
|
||||
type Prompt,
|
||||
type PromptModel,
|
||||
type PromptScope,
|
||||
type ComposerState,
|
||||
} from "./state"
|
||||
type PromptSession,
|
||||
} from "./prompt-state"
|
||||
|
||||
export {
|
||||
createComposerReady,
|
||||
createComposerState,
|
||||
createMemoryComposerState,
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
createPromptState,
|
||||
DEFAULT_PROMPT,
|
||||
isCommentItem,
|
||||
} from "./state"
|
||||
isPromptEqual,
|
||||
} from "./prompt-state"
|
||||
export type {
|
||||
AgentPart,
|
||||
ContentPart,
|
||||
@@ -35,11 +36,11 @@ export type {
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
ComposerStore,
|
||||
PromptStore,
|
||||
PromptScope,
|
||||
ComposerState,
|
||||
PromptSession,
|
||||
TextPart,
|
||||
} from "./state"
|
||||
} from "./prompt-state"
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_PROMPT_SESSIONS = 20
|
||||
@@ -58,19 +59,19 @@ function scopeKey(scope: PromptScope) {
|
||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
type ComposerCacheEntry = {
|
||||
value: ComposerState
|
||||
type PromptCacheEntry = {
|
||||
value: PromptSession
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
export const createTabComposerState = (
|
||||
export const createTabPromptState = (
|
||||
tabs: ReturnType<typeof useTabs>,
|
||||
tab: Tab,
|
||||
...args: Parameters<typeof createComposerState>
|
||||
) => tabs.state(tab, "prompt", () => createComposerState(...args))
|
||||
...args: Parameters<typeof createPromptSession>
|
||||
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
|
||||
|
||||
export const { use: useComposerState, provider: ComposerPersistenceProvider } = createSimpleContext({
|
||||
name: "ComposerState",
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
@@ -78,7 +79,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const cache = new Map<string, ComposerCacheEntry>()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) entry.dispose()
|
||||
@@ -104,7 +105,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
|
||||
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 createTabComposerState(tabs, current, target?.scope ?? serverSDK.scope, scope)
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK.scope, scope)
|
||||
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
@@ -116,7 +117,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createComposerState(target?.scope ?? serverSDK.scope, scope),
|
||||
value: createPromptSession(target?.scope ?? serverSDK.scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -130,7 +131,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createComposerReady(session)
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
createResource(
|
||||
@@ -149,6 +150,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
|
||||
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),
|
||||
@@ -26,6 +26,7 @@ export interface Settings {
|
||||
general: {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
followup: "queue" | "steer"
|
||||
showFileTree: boolean
|
||||
showNavigation: boolean
|
||||
showSearch: boolean
|
||||
@@ -111,6 +112,7 @@ const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
@@ -174,6 +176,11 @@ 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() {
|
||||
@@ -188,6 +195,13 @@ 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)
|
||||
|
||||
@@ -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 { createDraftComposerState, type PromptModel } from "@/composer/state"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-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", () => createDraftComposerState(draftID, { prompt, model }))
|
||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||
await startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
|
||||
@@ -174,6 +174,10 @@ 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": "ፋይሎችን፣ ትዕዛዞችን እና ክፍለ-ጊዜዎችን ይፈልጉ",
|
||||
@@ -581,6 +585,11 @@ 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": "ክፍለ ጊዜን አለማጋራት",
|
||||
@@ -708,6 +717,17 @@ 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": "የፍለጋ ፕሮጀክቶች",
|
||||
@@ -754,7 +774,17 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "ፕለጊኖች",
|
||||
"status.popover.action.manageServers": "አገልጋዮችን አስተዳድር",
|
||||
"common.copied": "የተገለበጠ",
|
||||
"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": "መገልበጥ አገናኝ",
|
||||
"lsp.tooltip.none": "ምንም LSP አገልጋዮች",
|
||||
"lsp.label.connected": "{{count}}LSP",
|
||||
"prompt.loading": "ፕሮምፕትን በመጫን ላይ...",
|
||||
@@ -879,6 +909,10 @@ 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": "የአሰሳ መቆጣጠሪያዎች",
|
||||
|
||||
@@ -180,6 +180,10 @@ 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",
|
||||
|
||||
@@ -585,6 +589,11 @@ 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": "فشل إلغاء مشاركة الجلسة",
|
||||
@@ -703,6 +712,25 @@ 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": "البحث عن المشاريع",
|
||||
@@ -730,7 +758,17 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "الإضافات",
|
||||
"status.popover.action.manageServers": "إدارة الخوادم",
|
||||
"common.copied": "تم النسخ",
|
||||
"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": "نسخ الرابط",
|
||||
"lsp.tooltip.none": "لا توجد خوادم LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "جارٍ تحميل الموجه...",
|
||||
@@ -808,6 +846,10 @@ 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": "عناصر التحكم في التنقل",
|
||||
|
||||
@@ -176,6 +176,10 @@ 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",
|
||||
|
||||
@@ -595,6 +599,11 @@ 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",
|
||||
@@ -729,6 +738,17 @@ 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",
|
||||
@@ -775,7 +795,18 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Plaginlər",
|
||||
"status.popover.action.manageServers": "Serverləri idarə et",
|
||||
"common.copied": "Kopyalandı",
|
||||
"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",
|
||||
"lsp.tooltip.none": "LSP server yoxdur",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Prompt yüklənir...",
|
||||
@@ -905,6 +936,11 @@ 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",
|
||||
|
||||
@@ -177,6 +177,10 @@ 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": "Търсене на файлове, команди и сесии",
|
||||
@@ -593,6 +597,11 @@ 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": "Прекратяването на споделянето на сесията не бе успешно",
|
||||
@@ -725,6 +734,17 @@ 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": "Търсене на проекти",
|
||||
@@ -771,7 +791,18 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Плъгини",
|
||||
"status.popover.action.manageServers": "Управление на сървъри",
|
||||
"common.copied": "Копирано",
|
||||
"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": "Копиране на връзката",
|
||||
"lsp.tooltip.none": "Няма LSP сървъри",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Подканата се зарежда...",
|
||||
@@ -901,6 +932,11 @@ 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": "Контроли за навигация",
|
||||
|
||||
@@ -175,6 +175,10 @@ export const dict: Record<string, string> = {
|
||||
"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": "অনুসন্ধান ফাইল, কমান্ড, এবং সেশন",
|
||||
@@ -588,6 +592,11 @@ export const dict: Record<string, string> = {
|
||||
"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": "সেশন শেয়ার মুক্ত করতে ব্যর্থ হয়েছে",
|
||||
@@ -719,6 +728,17 @@ export const dict: Record<string, string> = {
|
||||
"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": "অনুসন্ধান প্রকল্প",
|
||||
@@ -765,7 +785,18 @@ export const dict: Record<string, string> = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "প্লাগইন",
|
||||
"status.popover.action.manageServers": "সার্ভার পরিচালনা করুন",
|
||||
"common.copied": "কপি করা হয়েছে",
|
||||
"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": "লিঙ্ক কপি করুন",
|
||||
"lsp.tooltip.none": "কোনো LSP সার্ভার নেই",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "প্রম্পট লোড হচ্ছে...",
|
||||
@@ -893,6 +924,10 @@ export const dict: Record<string, string> = {
|
||||
"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": "নেভিগেশন নিয়ন্ত্রণ",
|
||||
|
||||
@@ -182,6 +182,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Resumir a sessão para reduzir o tamanho do contexto",
|
||||
"command.session.fork": "Bifurcar da mensagem",
|
||||
"command.session.fork.description": "Criar uma nova sessão a partir de uma mensagem anterior",
|
||||
"command.session.share": "Compartilhar sessão",
|
||||
"command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência",
|
||||
"command.session.unshare": "Parar de compartilhar sessão",
|
||||
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
||||
"command.session.export": "Exportar sessão",
|
||||
"command.session.export.description": "Exportar a transcrição completa da sessão como JSON",
|
||||
|
||||
@@ -589,6 +593,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Falha ao listar arquivos",
|
||||
"toast.context.noLineSelection.title": "Nenhuma seleção de linhas",
|
||||
"toast.context.noLineSelection.description": "Selecione primeiro um intervalo de linhas em uma aba de arquivo.",
|
||||
"toast.session.share.copyFailed.title": "Falha ao copiar URL para a área de transferência",
|
||||
"toast.session.share.success.title": "Sessão compartilhada",
|
||||
"toast.session.share.success.description": "URL compartilhada copiada para a área de transferência!",
|
||||
"toast.session.share.failed.title": "Falha ao compartilhar sessão",
|
||||
"toast.session.share.failed.description": "Ocorreu um erro ao compartilhar a sessão",
|
||||
"toast.session.unshare.success.title": "Sessão não compartilhada",
|
||||
"toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!",
|
||||
"toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão",
|
||||
@@ -708,6 +717,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} pergunta pendente",
|
||||
"session.question.pending.many": "{{count}} de perguntas pendentes",
|
||||
"session.question.pending.other": "{{count}} perguntas pendentes",
|
||||
"session.followupDock.summary.one": "{{count}} mensagem na fila",
|
||||
"session.followupDock.summary.many": "{{count}} de mensagens na fila",
|
||||
"session.followupDock.summary.other": "{{count}} mensagens na fila",
|
||||
"session.followupDock.sendNow": "Enviar agora",
|
||||
"session.followupDock.edit": "Editar",
|
||||
"session.followupDock.collapse": "Recolher mensagens na fila",
|
||||
"session.followupDock.expand": "Expandir mensagens na fila",
|
||||
"session.revertDock.summary.one": "{{count}} mensagem revertida",
|
||||
"session.revertDock.summary.many": "{{count}} de mensagens revertidas",
|
||||
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
||||
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||
"session.revertDock.restore": "Restaurar mensagem",
|
||||
"session.new.title": "Crie qualquer coisa",
|
||||
"session.new.project.new": "Novo projeto",
|
||||
"session.new.project.search": "Buscar projetos",
|
||||
@@ -735,7 +757,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Plugins",
|
||||
"status.popover.action.manageServers": "Gerenciar servidores",
|
||||
"common.copied": "Copiado",
|
||||
"session.share.popover.title": "Publicar na web",
|
||||
"session.share.popover.description.shared":
|
||||
"Esta sessão é pública na web. Está acessível para qualquer pessoa com o link.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Compartilhar sessão publicamente na web. Estará acessível para qualquer pessoa com o link.",
|
||||
"session.share.action.share": "Compartilhar",
|
||||
"session.share.action.publish": "Publicar",
|
||||
"session.share.action.publishing": "Publicando...",
|
||||
"session.share.action.unpublish": "Cancelar publicação",
|
||||
"session.share.action.unpublishing": "Cancelando publicação...",
|
||||
"session.share.action.view": "Ver",
|
||||
"session.share.copy.copied": "Copiado",
|
||||
"session.share.copy.copyLink": "Copiar link",
|
||||
"lsp.tooltip.none": "Nenhum servidor LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Carregando prompt...",
|
||||
@@ -815,6 +849,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal",
|
||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
"settings.general.row.followup.description":
|
||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||
"settings.general.row.followup.option.queue": "Fila",
|
||||
"settings.general.row.followup.option.steer": "Orientar",
|
||||
"settings.general.row.showFileTree.title": "Árvore de arquivos",
|
||||
"settings.general.row.showFileTree.description": "Mostrar o painel da árvore de arquivos nas sessões",
|
||||
"settings.general.row.showNavigation.title": "Controles de navegação",
|
||||
|
||||
@@ -188,6 +188,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Sažmi sesiju kako bi se smanjio kontekst",
|
||||
"command.session.fork": "Fork iz poruke",
|
||||
"command.session.fork.description": "Kreiraj novu sesiju iz prethodne poruke",
|
||||
"command.session.share": "Podijeli sesiju",
|
||||
"command.session.share.description": "Podijeli ovu sesiju i kopiraj URL u međuspremnik",
|
||||
"command.session.unshare": "Ukini dijeljenje sesije",
|
||||
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
|
||||
|
||||
"command.session.export": "Izvezi sesiju",
|
||||
"command.session.export.description": "Izvezi cijeli zapis sesije u JSON formatu",
|
||||
@@ -631,6 +635,11 @@ export const dict = {
|
||||
"toast.context.noLineSelection.title": "Nema odabranih linija",
|
||||
"toast.context.noLineSelection.description": "Prvo odaberi raspon linija u kartici datoteke.",
|
||||
|
||||
"toast.session.share.copyFailed.title": "Neuspjelo kopiranje URL-a u međuspremnik",
|
||||
"toast.session.share.success.title": "Sesija podijeljena",
|
||||
"toast.session.share.success.description": "URL za dijeljenje je kopiran u međuspremnik!",
|
||||
"toast.session.share.failed.title": "Neuspjelo dijeljenje sesije",
|
||||
"toast.session.share.failed.description": "Došlo je do greške prilikom dijeljenja sesije",
|
||||
|
||||
"toast.session.unshare.success.title": "Dijeljenje sesije ukinuto",
|
||||
"toast.session.unshare.success.description": "Dijeljenje sesije je uspješno ukinuto!",
|
||||
@@ -763,6 +772,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
||||
"session.question.pending.few": "{{count}} pitanja na čekanju",
|
||||
"session.question.pending.other": "{{count}} pitanja na čekanju",
|
||||
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
|
||||
"session.followupDock.summary.few": "{{count}} poruke na čekanju",
|
||||
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
|
||||
"session.followupDock.sendNow": "Pošalji sada",
|
||||
"session.followupDock.edit": "Uredi",
|
||||
"session.followupDock.collapse": "Sažmi poruke na čekanju",
|
||||
"session.followupDock.expand": "Proširi poruke na čekanju",
|
||||
"session.revertDock.summary.one": "{{count}} vraćena poruka",
|
||||
"session.revertDock.summary.few": "{{count}} vraćene poruke",
|
||||
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||
"session.revertDock.restore": "Vrati poruku",
|
||||
|
||||
"session.new.title": "Napravi bilo šta",
|
||||
"session.new.project.new": "Novi projekat",
|
||||
@@ -794,7 +816,17 @@ export const dict = {
|
||||
"status.popover.tab.plugins": "Plugini",
|
||||
"status.popover.action.manageServers": "Upravljaj serverima",
|
||||
|
||||
"common.copied": "Kopirano",
|
||||
"session.share.popover.title": "Objavi na webu",
|
||||
"session.share.popover.description.shared": "Ova sesija je javna na webu. Dostupna je svima koji imaju link.",
|
||||
"session.share.popover.description.unshared": "Podijeli sesiju javno na webu. Biće dostupna svima koji imaju link.",
|
||||
"session.share.action.share": "Podijeli",
|
||||
"session.share.action.publish": "Objavi",
|
||||
"session.share.action.publishing": "Objavljivanje...",
|
||||
"session.share.action.unpublish": "Poništi objavu",
|
||||
"session.share.action.unpublishing": "Poništavanje objave...",
|
||||
"session.share.action.view": "Prikaži",
|
||||
"session.share.copy.copied": "Kopirano",
|
||||
"session.share.copy.copyLink": "Kopiraj link",
|
||||
|
||||
"lsp.tooltip.none": "Nema LSP servera",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
@@ -881,6 +913,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Prilagodi font koji se koristi u terminalu",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||
"settings.general.row.followup.option.steer": "Usmjeri",
|
||||
"settings.general.row.showFileTree.title": "Stablo datoteka",
|
||||
"settings.general.row.showFileTree.description": "Prikaži stablo datoteka u sesijama",
|
||||
"settings.general.row.showNavigation.title": "Kontrole navigacije",
|
||||
|
||||
@@ -176,6 +176,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Resumeix la sessió per reduir la mida del context",
|
||||
"command.session.fork": "Bifurcació del missatge",
|
||||
"command.session.fork.description": "Crea una sessió nova a partir d'un missatge anterior",
|
||||
"command.session.share": "Compartir sessió",
|
||||
"command.session.share.description": "Comparteix aquesta sessió i copia el URL al porta-retalls",
|
||||
"command.session.unshare": "Deixa de compartir la sessió",
|
||||
"command.session.unshare.description": "Deixa de compartir aquesta sessió",
|
||||
"command.session.export": "Sessió d'exportació",
|
||||
"command.session.export.description": "Exporta la transcripció completa de la sessió com a JSON",
|
||||
"palette.search.placeholder": "Cerca fitxers, ordres i sessions",
|
||||
@@ -591,6 +595,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "No s'han pogut llistar els fitxers",
|
||||
"toast.context.noLineSelection.title": "Sense selecció de línia",
|
||||
"toast.context.noLineSelection.description": "Seleccioneu primer un interval de línies en una pestanya de fitxer.",
|
||||
"toast.session.share.copyFailed.title": "No s'ha pogut copiar URL al porta-retalls",
|
||||
"toast.session.share.success.title": "Sessió compartida",
|
||||
"toast.session.share.success.description": "Comparteix URL copiat al porta-retalls!",
|
||||
"toast.session.share.failed.title": "No s'ha pogut compartir la sessió",
|
||||
"toast.session.share.failed.description": "S'ha produït un error en compartir la sessió",
|
||||
"toast.session.unshare.success.title": "S'ha deixat de compartir la sessió",
|
||||
"toast.session.unshare.success.description": "S'ha deixat de compartir la sessió correctament.",
|
||||
"toast.session.unshare.failed.title": "No s'ha pogut deixar de compartir la sessió",
|
||||
@@ -725,6 +734,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} pregunta pendent",
|
||||
"session.question.pending.other": "{{count}} preguntes pendents",
|
||||
"session.question.pending.many": "{{count}} preguntes pendents",
|
||||
"session.followupDock.summary.one": "{{count}} missatge a la cua",
|
||||
"session.followupDock.summary.other": "{{count}} missatges a la cua",
|
||||
"session.followupDock.summary.many": "{{count}} missatges a la cua",
|
||||
"session.followupDock.sendNow": "Envia ara",
|
||||
"session.followupDock.edit": "Edita",
|
||||
"session.followupDock.collapse": "Replega els missatges a la cua",
|
||||
"session.followupDock.expand": "Amplieu els missatges a la cua",
|
||||
"session.revertDock.summary.one": "{{count}} missatge revertit",
|
||||
"session.revertDock.summary.other": "{{count}} missatges revertits",
|
||||
"session.revertDock.summary.many": "{{count}} missatges revertits",
|
||||
"session.revertDock.collapse": "Replega els missatges desfets",
|
||||
"session.revertDock.expand": "Amplieu els missatges revertits",
|
||||
"session.revertDock.restore": "Restaura el missatge",
|
||||
"session.new.title": "Construeix qualsevol cosa",
|
||||
"session.new.project.new": "Nou projecte",
|
||||
"session.new.project.search": "Cerca projectes",
|
||||
@@ -771,7 +793,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Connectors",
|
||||
"status.popover.action.manageServers": "Gestionar servidors",
|
||||
"common.copied": "Copiat",
|
||||
"session.share.popover.title": "Publicar a la web",
|
||||
"session.share.popover.description.shared":
|
||||
"Aquesta sessió és pública a la web. És accessible per a qualsevol persona que tingui l'enllaç.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Comparteix la sessió públicament al web. Serà accessible per a qualsevol persona que tingui l'enllaç.",
|
||||
"session.share.action.share": "Comparteix",
|
||||
"session.share.action.publish": "Publicar",
|
||||
"session.share.action.publishing": "S'està publicant...",
|
||||
"session.share.action.unpublish": "Despublicar",
|
||||
"session.share.action.unpublishing": "S'està anul·lant la publicació...",
|
||||
"session.share.action.view": "Veure",
|
||||
"session.share.copy.copied": "Copiat",
|
||||
"session.share.copy.copyLink": "Copia l'enllaç",
|
||||
"lsp.tooltip.none": "No hi ha LSP servidors",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "S'està carregant el missatge...",
|
||||
@@ -900,6 +934,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Personalitzeu el tipus de lletra utilitzat al terminal",
|
||||
"settings.general.row.uiFont.title": "Tipus de lletra de la interfície",
|
||||
"settings.general.row.uiFont.description": "Personalitzeu el tipus de lletra utilitzat a tota la interfície",
|
||||
"settings.general.row.followup.title": "Comportament de seguiment",
|
||||
"settings.general.row.followup.description":
|
||||
"Trieu si les indicacions de seguiment es dirigeixen immediatament o esperen en una cua",
|
||||
"settings.general.row.followup.option.queue": "Cua",
|
||||
"settings.general.row.followup.option.steer": "Dirigeix",
|
||||
"settings.general.row.showFileTree.title": "Arbre de fitxers",
|
||||
"settings.general.row.showFileTree.description": "Mostra el tauler de l'arbre de fitxers a les sessions",
|
||||
"settings.general.row.showNavigation.title": "Controls de navegació",
|
||||
|
||||
@@ -174,6 +174,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Shrňte relaci, abyste snížili velikost kontextu",
|
||||
"command.session.fork": "Vytvořit větev ze zprávy",
|
||||
"command.session.fork.description": "Vytvořte novou relaci z předchozí zprávy",
|
||||
"command.session.share": "Sdílejte relaci",
|
||||
"command.session.share.description": "Sdílejte tuto relaci a zkopírujte URL do schránky",
|
||||
"command.session.unshare": "Zrušit sdílení relace",
|
||||
"command.session.unshare.description": "Přestat sdílet tuto relaci",
|
||||
"command.session.export": "Export relace",
|
||||
"command.session.export.description": "Exportovat celý přepis relace jako JSON",
|
||||
"palette.search.placeholder": "Prohledávejte soubory, příkazy a relace",
|
||||
@@ -589,6 +593,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Seznam souborů se nezdařil",
|
||||
"toast.context.noLineSelection.title": "Žádný výběr řádku",
|
||||
"toast.context.noLineSelection.description": "Nejprve vyberte rozsah řádků na kartě souboru.",
|
||||
"toast.session.share.copyFailed.title": "Zkopírování URL do schránky se nezdařilo",
|
||||
"toast.session.share.success.title": "Relace sdílena",
|
||||
"toast.session.share.success.description": "Sdílet URL zkopírováno do schránky!",
|
||||
"toast.session.share.failed.title": "Sdílení relace se nezdařilo",
|
||||
"toast.session.share.failed.description": "Při sdílení relace došlo k chybě",
|
||||
"toast.session.unshare.success.title": "Relace byla zrušena",
|
||||
"toast.session.unshare.success.description": "Sdílení relace bylo úspěšně zrušeno!",
|
||||
"toast.session.unshare.failed.title": "Zrušení sdílení relace se nezdařilo",
|
||||
@@ -721,6 +730,21 @@ export const dict = {
|
||||
"session.question.pending.other": "{{count}} nevyřízené otázky",
|
||||
"session.question.pending.few": "{{count}} nevyřízené otázky",
|
||||
"session.question.pending.many": "{{count}} nevyřízených otázek",
|
||||
"session.followupDock.summary.one": "{{count}} zpráva ve frontě",
|
||||
"session.followupDock.summary.other": "{{count}} zpráv ve frontě",
|
||||
"session.followupDock.summary.few": "{{count}} zprávy ve frontě",
|
||||
"session.followupDock.summary.many": "{{count}} zprávy ve frontě",
|
||||
"session.followupDock.sendNow": "Poslat nyní",
|
||||
"session.followupDock.edit": "Upravit",
|
||||
"session.followupDock.collapse": "Sbalit zprávy ve frontě",
|
||||
"session.followupDock.expand": "Rozbalte zprávy ve frontě",
|
||||
"session.revertDock.summary.one": "{{count}} vrácená zpráva",
|
||||
"session.revertDock.summary.other": "{{count}} vrátilo zpět zprávy",
|
||||
"session.revertDock.summary.few": "{{count}} vrácené zprávy",
|
||||
"session.revertDock.summary.many": "{{count}} vrácené zprávy",
|
||||
"session.revertDock.collapse": "Sbalit vrácené zprávy",
|
||||
"session.revertDock.expand": "Rozbalte vrácené zprávy",
|
||||
"session.revertDock.restore": "Obnovit zprávu",
|
||||
"session.new.title": "Postavte cokoliv",
|
||||
"session.new.project.new": "Nový projekt",
|
||||
"session.new.project.search": "Hledat projekty",
|
||||
@@ -767,7 +791,17 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Pluginy",
|
||||
"status.popover.action.manageServers": "Správa serverů",
|
||||
"common.copied": "Zkopírováno",
|
||||
"session.share.popover.title": "Publikovat na webu",
|
||||
"session.share.popover.description.shared": "Tato relace je veřejná na webu. Je přístupný komukoli s odkazem.",
|
||||
"session.share.popover.description.unshared": "Sdílejte relaci veřejně na webu. Bude přístupný komukoli s odkazem.",
|
||||
"session.share.action.share": "Sdílejte",
|
||||
"session.share.action.publish": "Publikovat",
|
||||
"session.share.action.publishing": "Publikování...",
|
||||
"session.share.action.unpublish": "Zrušit publikování",
|
||||
"session.share.action.unpublishing": "Rušení publikování...",
|
||||
"session.share.action.view": "Zobrazit",
|
||||
"session.share.copy.copied": "Zkopírováno",
|
||||
"session.share.copy.copyLink": "Zkopírujte odkaz",
|
||||
"lsp.tooltip.none": "Žádné LSP servery",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Načítání výzvy...",
|
||||
@@ -898,6 +932,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Přizpůsobte písmo použité v terminálu",
|
||||
"settings.general.row.uiFont.title": "UI Písmo",
|
||||
"settings.general.row.uiFont.description": "Přizpůsobte písmo používané v celém rozhraní",
|
||||
"settings.general.row.followup.title": "Následné chování",
|
||||
"settings.general.row.followup.description": "Zvolte, zda se mají následné výzvy řídit okamžitě nebo čekat ve frontě",
|
||||
"settings.general.row.followup.option.queue": "Fronta",
|
||||
"settings.general.row.followup.option.steer": "Řídit",
|
||||
"settings.general.row.showFileTree.title": "Strom souborů",
|
||||
"settings.general.row.showFileTree.description": "Zobrazit panel stromu souborů v relacích",
|
||||
"settings.general.row.showNavigation.title": "Ovládací prvky navigace",
|
||||
|
||||
@@ -87,6 +87,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Opsummer sessionen for at reducere kontekststørrelsen",
|
||||
"command.session.fork": "Forgren fra besked",
|
||||
"command.session.fork.description": "Opret en ny session fra en tidligere besked",
|
||||
"command.session.share": "Del session",
|
||||
"command.session.share.description": "Del denne session og kopier URL'en til udklipsholderen",
|
||||
"command.session.unshare": "Stop deling af session",
|
||||
"command.session.unshare.description": "Stop med at dele denne session",
|
||||
|
||||
"command.session.export": "Eksportér session",
|
||||
"command.session.export.description": "Eksportér hele sessionsudskriften som JSON",
|
||||
@@ -527,6 +531,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Kunne ikke liste filer",
|
||||
"toast.context.noLineSelection.title": "Ingen linjevalg",
|
||||
"toast.context.noLineSelection.description": "Vælg først et linjeinterval i en filfane.",
|
||||
"toast.session.share.copyFailed.title": "Kunne ikke kopiere URL til udklipsholder",
|
||||
"toast.session.share.success.title": "Session delt",
|
||||
"toast.session.share.success.description": "Delings-URL kopieret til udklipsholder!",
|
||||
"toast.session.share.failed.title": "Kunne ikke dele session",
|
||||
"toast.session.share.failed.description": "Der opstod en fejl under deling af sessionen",
|
||||
|
||||
"toast.session.unshare.success.title": "Deling af session stoppet",
|
||||
"toast.session.unshare.success.description": "Deling af session blev stoppet!",
|
||||
@@ -656,6 +665,17 @@ export const dict = {
|
||||
"session.question.restore": "Gendan spørgsmål",
|
||||
"session.question.pending.one": "{{count}} afventende spørgsmål",
|
||||
"session.question.pending.other": "{{count}} afventende spørgsmål",
|
||||
"session.followupDock.summary.one": "{{count}} besked i kø",
|
||||
"session.followupDock.summary.other": "{{count}} beskeder i kø",
|
||||
"session.followupDock.sendNow": "Send nu",
|
||||
"session.followupDock.edit": "Rediger",
|
||||
"session.followupDock.collapse": "Skjul beskeder i kø",
|
||||
"session.followupDock.expand": "Udvid beskeder i kø",
|
||||
"session.revertDock.summary.one": "{{count}} tilbagerullet besked",
|
||||
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
|
||||
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
|
||||
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
|
||||
"session.revertDock.restore": "Gendan besked",
|
||||
|
||||
"session.new.title": "Byg hvad som helst",
|
||||
"session.new.project.new": "Nyt projekt",
|
||||
@@ -687,7 +707,19 @@ export const dict = {
|
||||
"status.popover.tab.plugins": "Plugins",
|
||||
"status.popover.action.manageServers": "Administrer servere",
|
||||
|
||||
"common.copied": "Kopieret",
|
||||
"session.share.popover.title": "Udgiv på nettet",
|
||||
"session.share.popover.description.shared":
|
||||
"Denne session er offentlig på nettet. Den er tilgængelig for alle med linket.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Del session offentligt på nettet. Den vil være tilgængelig for alle med linket.",
|
||||
"session.share.action.share": "Del",
|
||||
"session.share.action.publish": "Udgiv",
|
||||
"session.share.action.publishing": "Udgiver...",
|
||||
"session.share.action.unpublish": "Afpublicer",
|
||||
"session.share.action.unpublishing": "Afpublicerer...",
|
||||
"session.share.action.view": "Vis",
|
||||
"session.share.copy.copied": "Kopieret",
|
||||
"session.share.copy.copyLink": "Kopier link",
|
||||
|
||||
"lsp.tooltip.none": "Ingen LSP-servere",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
@@ -774,6 +806,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
|
||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugergrænsefladen",
|
||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
"settings.general.row.followup.option.steer": "Styr",
|
||||
"settings.general.row.showFileTree.title": "Filtræ",
|
||||
"settings.general.row.showFileTree.description": "Vis filtræspanelet i sessioner",
|
||||
"settings.general.row.showNavigation.title": "Navigationsknapper",
|
||||
|
||||
@@ -85,6 +85,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Sitzung zusammenfassen, um die Kontextgröße zu reduzieren",
|
||||
"command.session.fork": "Von Nachricht abzweigen",
|
||||
"command.session.fork.description": "Neue Sitzung aus einer früheren Nachricht erstellen",
|
||||
"command.session.share": "Sitzung teilen",
|
||||
"command.session.share.description": "Diese Sitzung teilen und URL in die Zwischenablage kopieren",
|
||||
"command.session.unshare": "Teilen der Sitzung aufheben",
|
||||
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
|
||||
"command.session.export": "Sitzung exportieren",
|
||||
"command.session.export.description": "Das vollständige Transkript der Sitzung als JSON exportieren",
|
||||
|
||||
@@ -495,6 +499,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Dateien konnten nicht aufgelistet werden",
|
||||
"toast.context.noLineSelection.title": "Keine Zeilenauswahl",
|
||||
"toast.context.noLineSelection.description": "Wählen Sie zuerst einen Zeilenbereich in einem Datei-Tab aus.",
|
||||
"toast.session.share.copyFailed.title": "URL konnte nicht in die Zwischenablage kopiert werden",
|
||||
"toast.session.share.success.title": "Sitzung geteilt",
|
||||
"toast.session.share.success.description": "URL zum Teilen in die Zwischenablage kopiert!",
|
||||
"toast.session.share.failed.title": "Sitzung konnte nicht geteilt werden",
|
||||
"toast.session.share.failed.description": "Beim Teilen der Sitzung ist ein Fehler aufgetreten",
|
||||
"toast.session.unshare.success.title": "Teilen der Sitzung aufgehoben",
|
||||
"toast.session.unshare.success.description": "Teilen der Sitzung erfolgreich aufgehoben!",
|
||||
"toast.session.unshare.failed.title": "Aufheben des Teilens fehlgeschlagen",
|
||||
@@ -615,6 +624,17 @@ export const dict = {
|
||||
"session.question.restore": "Frage wiederherstellen",
|
||||
"session.question.pending.one": "{{count}} ausstehende Frage",
|
||||
"session.question.pending.other": "{{count}} ausstehende Fragen",
|
||||
"session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange",
|
||||
"session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange",
|
||||
"session.followupDock.sendNow": "Jetzt senden",
|
||||
"session.followupDock.edit": "Bearbeiten",
|
||||
"session.followupDock.collapse": "Warteschlange einklappen",
|
||||
"session.followupDock.expand": "Warteschlange ausklappen",
|
||||
"session.revertDock.summary.one": "{{count}} zurückgesetzte Nachricht",
|
||||
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
|
||||
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
|
||||
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
||||
"session.revertDock.restore": "Nachricht wiederherstellen",
|
||||
"session.new.title": "Alles entwickeln",
|
||||
"session.new.project.new": "Neues Projekt",
|
||||
"session.new.project.search": "Projekte durchsuchen",
|
||||
@@ -642,7 +662,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Plugins",
|
||||
"status.popover.action.manageServers": "Server verwalten",
|
||||
"common.copied": "Kopiert",
|
||||
"session.share.popover.title": "Im Web veröffentlichen",
|
||||
"session.share.popover.description.shared":
|
||||
"Diese Sitzung ist öffentlich im Web. Sie ist für jeden mit dem Link zugänglich.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Sitzung öffentlich im Web teilen. Sie wird für jeden mit dem Link zugänglich sein.",
|
||||
"session.share.action.share": "Teilen",
|
||||
"session.share.action.publish": "Veröffentlichen",
|
||||
"session.share.action.publishing": "Wird veröffentlicht…",
|
||||
"session.share.action.unpublish": "Veröffentlichung aufheben",
|
||||
"session.share.action.unpublishing": "Veröffentlichung wird aufgehoben…",
|
||||
"session.share.action.view": "Ansehen",
|
||||
"session.share.copy.copied": "Kopiert",
|
||||
"session.share.copy.copyLink": "Link kopieren",
|
||||
"lsp.tooltip.none": "Keine LSP-Server",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Prompt wird geladen…",
|
||||
@@ -724,6 +756,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Die im Terminal verwendete Schriftart anpassen",
|
||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
||||
"settings.general.row.uiFont.description": "Die in der gesamten Benutzeroberfläche verwendete Schriftart anpassen",
|
||||
"settings.general.row.followup.title": "Verhalten bei Folgeeingaben",
|
||||
"settings.general.row.followup.description":
|
||||
"Wählen Sie, ob Folgeeingaben die laufende Sitzung sofort steuern oder in einer Warteschlange warten",
|
||||
"settings.general.row.followup.option.queue": "Warteschlange",
|
||||
"settings.general.row.followup.option.steer": "Steuern",
|
||||
"settings.general.row.showFileTree.title": "Dateibaum",
|
||||
"settings.general.row.showFileTree.description": "Dateibaum in Sitzungen anzeigen",
|
||||
"settings.general.row.showNavigation.title": "Navigationssteuerung",
|
||||
|
||||
@@ -177,6 +177,10 @@ 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": "ފައިލްތަކާއި، ކޮމާންޑްތަކާއި، ސެޝަންތައް ހޯދުން",
|
||||
@@ -595,6 +599,11 @@ 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": "ސެޝަން އަންޝެއާ ކުރަން ފެއިލްވެއްޖެ",
|
||||
@@ -730,6 +739,17 @@ 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": "ޕްރޮޖެކްޓްތައް ހޯދުން",
|
||||
@@ -776,7 +796,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP އެވެ",
|
||||
"status.popover.tab.plugins": "ޕްލަގިންސް އެވެ",
|
||||
"status.popover.action.manageServers": "ސަރވަރތައް މެނޭޖްކުރުން",
|
||||
"common.copied": "ކޮޕީކޮށްފައި",
|
||||
"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": "ކޮޕީ ލިންކް",
|
||||
"lsp.tooltip.none": "LSP ސަރވަރެއް ނެތެވެ",
|
||||
"lsp.label.connected": "{{count}} LSP އެވެ",
|
||||
"prompt.loading": "ލޯޑިންގ ޕްރޮމްޕްޓް...",
|
||||
@@ -908,6 +940,11 @@ 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": "ނޭވިގޭޝަން ކޮންޓްރޯލްތައް",
|
||||
|
||||
@@ -177,6 +177,10 @@ export const dict: Record<string, string> = {
|
||||
"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": "ཡིག་སྣོད་དང་བརྡ་བཀོད་ དེ་ལས་ལཱ་ཡུན་ཚུ་འཚོལ་ཞིབ་འབད།",
|
||||
@@ -595,6 +599,11 @@ export const dict: Record<string, string> = {
|
||||
"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": "ལཱ་ཡུན་བགོ་བཤའ་རྐྱབ་ནི་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
|
||||
@@ -730,6 +739,17 @@ export const dict: Record<string, string> = {
|
||||
"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": "ལས་གཞི་འཚོལ་ཞིབ།",
|
||||
@@ -776,7 +796,19 @@ export const dict: Record<string, string> = {
|
||||
"status.popover.tab.lsp": "LSP།",
|
||||
"status.popover.tab.plugins": "པ་ལག་ཨིན་ཚུ།",
|
||||
"status.popover.action.manageServers": "སར་བར་ཚུ་འཛིན་སྐྱོང་འཐབ།",
|
||||
"common.copied": "འདྲ་བཤུས་འབད་ཡོདཔ།",
|
||||
"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": "འབྲེལ་མཐུད་འདྲ་བཤུས།",
|
||||
"lsp.tooltip.none": "LSP སར་བར་ཚུ་མེད།",
|
||||
"lsp.label.connected": "{{count}} LSP།",
|
||||
"prompt.loading": "མངོན་གསལ་འབད་དོ།",
|
||||
@@ -909,6 +941,11 @@ export const dict: Record<string, string> = {
|
||||
"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": "འགྲུལ་བསྐྱོད་ཚད་འཛིན་ཚུ།",
|
||||
|
||||
@@ -175,6 +175,10 @@ 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": "Αναζήτηση αρχείων, εντολών και συνεδριών",
|
||||
@@ -592,6 +596,11 @@ 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": "Αποτυχία κατάργησης κοινής χρήσης συνεδρίας",
|
||||
@@ -727,6 +736,17 @@ 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": "Αναζήτηση έργων",
|
||||
@@ -773,7 +793,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Προσθήκες",
|
||||
"status.popover.action.manageServers": "Διαχείριση διακομιστών",
|
||||
"common.copied": "Αντιγράφηκε",
|
||||
"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": "Αντιγραφή συνδέσμου",
|
||||
"lsp.tooltip.none": "Δεν υπάρχουν LSP διακομιστές",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Φόρτωση προτροπής...",
|
||||
@@ -905,6 +937,11 @@ 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": "Στοιχεία ελέγχου πλοήγησης",
|
||||
|
||||
@@ -91,6 +91,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Summarize the session to reduce context size",
|
||||
"command.session.fork": "Fork from message",
|
||||
"command.session.fork.description": "Create a new session from a previous message",
|
||||
"command.session.share": "Share session",
|
||||
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
||||
"command.session.unshare": "Unshare session",
|
||||
"command.session.unshare.description": "Stop sharing this session",
|
||||
"command.session.export": "Export session",
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
|
||||
@@ -542,6 +546,11 @@ export const dict = {
|
||||
"toast.context.noLineSelection.title": "No line selection",
|
||||
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
|
||||
|
||||
"toast.session.share.copyFailed.title": "Failed to copy URL to clipboard",
|
||||
"toast.session.share.success.title": "Session shared",
|
||||
"toast.session.share.success.description": "Share URL copied to clipboard!",
|
||||
"toast.session.share.failed.title": "Failed to share session",
|
||||
"toast.session.share.failed.description": "An error occurred while sharing the session",
|
||||
|
||||
"toast.session.unshare.success.title": "Session unshared",
|
||||
"toast.session.unshare.success.description": "Session unshared successfully!",
|
||||
@@ -698,6 +707,17 @@ export const dict = {
|
||||
"session.question.restore": "Restore question",
|
||||
"session.question.pending.one": "{{count}} pending question",
|
||||
"session.question.pending.other": "{{count}} pending questions",
|
||||
"session.followupDock.summary.one": "{{count}} queued message",
|
||||
"session.followupDock.summary.other": "{{count}} queued messages",
|
||||
"session.followupDock.sendNow": "Send now",
|
||||
"session.followupDock.edit": "Edit",
|
||||
"session.followupDock.collapse": "Collapse queued messages",
|
||||
"session.followupDock.expand": "Expand queued messages",
|
||||
"session.revertDock.summary.one": "{{count}} rolled back message",
|
||||
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
||||
"session.revertDock.collapse": "Collapse rolled back messages",
|
||||
"session.revertDock.expand": "Expand rolled back messages",
|
||||
"session.revertDock.restore": "Restore message",
|
||||
|
||||
"session.new.title": "Build anything",
|
||||
"session.new.project.new": "New project",
|
||||
@@ -748,7 +768,19 @@ export const dict = {
|
||||
"status.popover.tab.plugins": "Plugins",
|
||||
"status.popover.action.manageServers": "Manage servers",
|
||||
|
||||
"common.copied": "Copied",
|
||||
"session.share.popover.title": "Publish on web",
|
||||
"session.share.popover.description.shared":
|
||||
"This session is public on the web. It is accessible to anyone with the link.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Share session publicly on the web. It will be accessible to anyone with the link.",
|
||||
"session.share.action.share": "Share",
|
||||
"session.share.action.publish": "Publish",
|
||||
"session.share.action.publishing": "Publishing...",
|
||||
"session.share.action.unpublish": "Unpublish",
|
||||
"session.share.action.unpublishing": "Unpublishing...",
|
||||
"session.share.action.view": "View",
|
||||
"session.share.copy.copied": "Copied",
|
||||
"session.share.copy.copyLink": "Copy link",
|
||||
|
||||
"lsp.tooltip.none": "No LSP servers",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
@@ -920,6 +952,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||
"settings.general.row.uiFont.title": "UI Font",
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.followup.title": "Follow-up behavior",
|
||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
|
||||
@@ -188,6 +188,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Resumir la sesión para reducir el tamaño del contexto",
|
||||
"command.session.fork": "Bifurcar desde mensaje",
|
||||
"command.session.fork.description": "Crear una nueva sesión desde un mensaje anterior",
|
||||
"command.session.share": "Compartir sesión",
|
||||
"command.session.share.description": "Compartir esta sesión y copiar la URL al portapapeles",
|
||||
"command.session.unshare": "Dejar de compartir sesión",
|
||||
"command.session.unshare.description": "Dejar de compartir esta sesión",
|
||||
|
||||
"command.session.export": "Exportar sesión",
|
||||
"command.session.export.description": "Exportar la transcripción completa de la sesión como JSON",
|
||||
@@ -633,6 +637,11 @@ export const dict = {
|
||||
"toast.context.noLineSelection.title": "Sin selección de líneas",
|
||||
"toast.context.noLineSelection.description": "Primero selecciona un rango de líneas en una pestaña de archivo.",
|
||||
|
||||
"toast.session.share.copyFailed.title": "Fallo al copiar URL al portapapeles",
|
||||
"toast.session.share.success.title": "Sesión compartida",
|
||||
"toast.session.share.success.description": "Enlace para compartir copiado al portapapeles.",
|
||||
"toast.session.share.failed.title": "Fallo al compartir sesión",
|
||||
"toast.session.share.failed.description": "Ocurrió un error al compartir la sesión",
|
||||
|
||||
"toast.session.unshare.success.title": "La sesión dejó de compartirse",
|
||||
"toast.session.unshare.success.description": "La sesión dejó de compartirse correctamente.",
|
||||
@@ -766,6 +775,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} pregunta pendiente",
|
||||
"session.question.pending.many": "{{count}} de preguntas pendientes",
|
||||
"session.question.pending.other": "{{count}} preguntas pendientes",
|
||||
"session.followupDock.summary.one": "{{count}} mensaje en cola",
|
||||
"session.followupDock.summary.many": "{{count}} de mensajes en cola",
|
||||
"session.followupDock.summary.other": "{{count}} mensajes en cola",
|
||||
"session.followupDock.sendNow": "Enviar ahora",
|
||||
"session.followupDock.edit": "Editar",
|
||||
"session.followupDock.collapse": "Contraer mensajes en cola",
|
||||
"session.followupDock.expand": "Expandir mensajes en cola",
|
||||
"session.revertDock.summary.one": "{{count}} mensaje revertido",
|
||||
"session.revertDock.summary.many": "{{count}} de mensajes revertidos",
|
||||
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
|
||||
"session.revertDock.collapse": "Contraer mensajes revertidos",
|
||||
"session.revertDock.expand": "Expandir mensajes revertidos",
|
||||
"session.revertDock.restore": "Restaurar mensaje",
|
||||
|
||||
"session.new.title": "Construye lo que quieras",
|
||||
"session.new.project.new": "Nuevo proyecto",
|
||||
@@ -797,7 +819,19 @@ export const dict = {
|
||||
"status.popover.tab.plugins": "Plugins",
|
||||
"status.popover.action.manageServers": "Gestionar servidores",
|
||||
|
||||
"common.copied": "Copiado",
|
||||
"session.share.popover.title": "Publicar en la web",
|
||||
"session.share.popover.description.shared":
|
||||
"Esta sesión es pública en la web. Es accesible para cualquiera con el enlace.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Compartir sesión públicamente en la web. Será accesible para cualquiera con el enlace.",
|
||||
"session.share.action.share": "Compartir",
|
||||
"session.share.action.publish": "Publicar",
|
||||
"session.share.action.publishing": "Publicando...",
|
||||
"session.share.action.unpublish": "Despublicar",
|
||||
"session.share.action.unpublishing": "Despublicando...",
|
||||
"session.share.action.view": "Ver",
|
||||
"session.share.copy.copied": "Copiado",
|
||||
"session.share.copy.copyLink": "Copiar enlace",
|
||||
|
||||
"lsp.tooltip.none": "Sin servidores LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
@@ -883,6 +917,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal",
|
||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||
"settings.general.row.followup.description":
|
||||
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
||||
"settings.general.row.followup.option.queue": "Cola",
|
||||
"settings.general.row.followup.option.steer": "Dirigir",
|
||||
"settings.general.row.showFileTree.title": "Árbol de archivos",
|
||||
"settings.general.row.showFileTree.description": "Mostrar el panel del árbol de archivos en las sesiones",
|
||||
"settings.general.row.showNavigation.title": "Controles de navegación",
|
||||
|
||||
@@ -174,6 +174,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Konteksti suuruse vähendamiseks tehke seansist kokkuvõte",
|
||||
"command.session.fork": "Sõnumi kahvel",
|
||||
"command.session.fork.description": "Looge eelmisest sõnumist uus seanss",
|
||||
"command.session.share": "Jaga seanssi",
|
||||
"command.session.share.description": "Jagage seda seanssi ja kopeerige URL lõikelauale",
|
||||
"command.session.unshare": "Tühista seansi jagamine",
|
||||
"command.session.unshare.description": "Lõpetage selle seansi jagamine",
|
||||
"command.session.export": "Ekspordiseanss",
|
||||
"command.session.export.description": "Ekspordi kogu seansi transkriptsioon kui JSON",
|
||||
"palette.search.placeholder": "Otsige faile, käske ja seansse",
|
||||
@@ -586,6 +590,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Failide loetlemine ebaõnnestus",
|
||||
"toast.context.noLineSelection.title": "Rea valikut pole",
|
||||
"toast.context.noLineSelection.description": "Valige faili vahekaardilt esmalt reavahemik.",
|
||||
"toast.session.share.copyFailed.title": "URL lõikelauale kopeerimine ebaõnnestus",
|
||||
"toast.session.share.success.title": "Seanss jagatud",
|
||||
"toast.session.share.success.description": "Jaga URL kopeeriti lõikelauale!",
|
||||
"toast.session.share.failed.title": "Seansi jagamine ebaõnnestus",
|
||||
"toast.session.share.failed.description": "Seansi jagamisel ilmnes viga",
|
||||
"toast.session.unshare.success.title": "Seansi jagamine tühistati",
|
||||
"toast.session.unshare.success.description": "Seansi jagamise tühistamine õnnestus!",
|
||||
"toast.session.unshare.failed.title": "Seansi jagamise tühistamine ebaõnnestus",
|
||||
@@ -716,6 +725,17 @@ export const dict = {
|
||||
"session.question.restore": "Taasta küsimus",
|
||||
"session.question.pending.one": "{{count}} ootel küsimus",
|
||||
"session.question.pending.other": "{{count}} ootel küsimust",
|
||||
"session.followupDock.summary.one": "{{count}} järjekorda pandud sõnum",
|
||||
"session.followupDock.summary.other": "{{count}} järjekorras olevat sõnumit",
|
||||
"session.followupDock.sendNow": "Saada kohe",
|
||||
"session.followupDock.edit": "Muuda",
|
||||
"session.followupDock.collapse": "Ahenda järjekorda pandud kirjad",
|
||||
"session.followupDock.expand": "Laiendage järjekorras olevaid sõnumeid",
|
||||
"session.revertDock.summary.one": "{{count}} tagasikeeratud sõnum",
|
||||
"session.revertDock.summary.other": "{{count}} tagasipööratud sõnumit",
|
||||
"session.revertDock.collapse": "Ahenda tagasi keritud sõnumid",
|
||||
"session.revertDock.expand": "Laienda tagasipööratud sõnumeid",
|
||||
"session.revertDock.restore": "Taasta sõnum",
|
||||
"session.new.title": "Ehitage midagi",
|
||||
"session.new.project.new": "Uus projekt",
|
||||
"session.new.project.search": "Otsige projekte",
|
||||
@@ -762,7 +782,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Pluginad",
|
||||
"status.popover.action.manageServers": "Hallake servereid",
|
||||
"common.copied": "Kopeeritud",
|
||||
"session.share.popover.title": "Avalda veebis",
|
||||
"session.share.popover.description.shared":
|
||||
"See seanss on veebis avalik. See on juurdepääsetav kõigile, kellel on link.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Jagage seanssi avalikult veebis. See on juurdepääsetav kõigile, kellel on link.",
|
||||
"session.share.action.share": "Jaga",
|
||||
"session.share.action.publish": "Avalda",
|
||||
"session.share.action.publishing": "Avaldamine...",
|
||||
"session.share.action.unpublish": "Tühista avaldamine",
|
||||
"session.share.action.unpublishing": "Avaldamise tühistamine...",
|
||||
"session.share.action.view": "Vaade",
|
||||
"session.share.copy.copied": "Kopeeritud",
|
||||
"session.share.copy.copyLink": "Kopeeri link",
|
||||
"lsp.tooltip.none": "LSP serverit pole",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Viipa laadimine...",
|
||||
@@ -891,6 +923,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Kohandage terminalis kasutatavat fonti",
|
||||
"settings.general.row.uiFont.title": "Kasutajaliidese font",
|
||||
"settings.general.row.uiFont.description": "Kohandage kogu liideses kasutatavat fonti",
|
||||
"settings.general.row.followup.title": "Järelkäitumine",
|
||||
"settings.general.row.followup.description": "Valige, kas järelviibad juhivad kohe või oodake järjekorras",
|
||||
"settings.general.row.followup.option.queue": "Järjekord",
|
||||
"settings.general.row.followup.option.steer": "Juhtida",
|
||||
"settings.general.row.showFileTree.title": "Failipuu",
|
||||
"settings.general.row.showFileTree.description": "Failipuu paneeli kuvamine seanssides",
|
||||
"settings.general.row.showNavigation.title": "Navigeerimisnupud",
|
||||
|
||||
@@ -175,6 +175,10 @@ 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": "فایل ها، دستورات و جلسات را جستجو کنید",
|
||||
@@ -588,6 +592,11 @@ 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": "لغو اشتراکگذاری جلسه انجام نشد",
|
||||
@@ -718,6 +727,17 @@ 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": "جستجوی پروژه ها",
|
||||
@@ -764,7 +784,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "پلاگین ها",
|
||||
"status.popover.action.manageServers": "مدیریت سرورها",
|
||||
"common.copied": "کپی شد",
|
||||
"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": "لینک را کپی کنید",
|
||||
"lsp.tooltip.none": "بدون سرور LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "در حال بارگیری درخواست...",
|
||||
@@ -891,6 +923,11 @@ 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": "کنترل های ناوبری",
|
||||
|
||||
@@ -81,6 +81,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Tee yhteenveto istunnosta pienentääksesi kontekstin kokoa",
|
||||
"command.session.fork": "Haarauta viestistä",
|
||||
"command.session.fork.description": "Luo uusi istunto edellisestä viestistä",
|
||||
"command.session.share": "Jaa istunto",
|
||||
"command.session.share.description": "Jaa tämä istunto ja kopioi URL-osoite leikepöydälle",
|
||||
"command.session.unshare": "Peru istunnon jakaminen",
|
||||
"command.session.unshare.description": "Lopeta tämän istunnon jakaminen",
|
||||
"command.session.export": "Vie istunto",
|
||||
"command.session.export.description": "Vie istunnon koko transkriptio JSON-muodossa",
|
||||
|
||||
@@ -499,6 +503,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Tiedostojen luettelointi epäonnistui",
|
||||
"toast.context.noLineSelection.title": "Ei rivivalintaa",
|
||||
"toast.context.noLineSelection.description": "Valitse ensin riviväli tiedostovälilehdeltä.",
|
||||
"toast.session.share.copyFailed.title": "URL-osoitteen kopioiminen leikepöydälle epäonnistui",
|
||||
"toast.session.share.success.title": "Istunto jaettu",
|
||||
"toast.session.share.success.description": "Jakolinkki kopioitu leikepöydälle!",
|
||||
"toast.session.share.failed.title": "Istunnon jakaminen epäonnistui",
|
||||
"toast.session.share.failed.description": "Istunnon jakamisessa tapahtui virhe",
|
||||
"toast.session.unshare.success.title": "Istunnon jako peruttu",
|
||||
"toast.session.unshare.success.description": "Istunnon jakaminen peruutettu onnistuneesti!",
|
||||
"toast.session.unshare.failed.title": "Istunnon jakamisen peruuttaminen epäonnistui",
|
||||
@@ -632,6 +641,17 @@ export const dict = {
|
||||
"session.question.restore": "Palauta kysymys",
|
||||
"session.question.pending.one": "{{count}} odottava kysymys",
|
||||
"session.question.pending.other": "{{count}} odottavaa kysymystä",
|
||||
"session.followupDock.summary.one": "{{count}} jonossa oleva viesti",
|
||||
"session.followupDock.summary.other": "{{count}} jonossa olevaa viestiä",
|
||||
"session.followupDock.sendNow": "Lähetä nyt",
|
||||
"session.followupDock.edit": "Muokkaa",
|
||||
"session.followupDock.collapse": "Kutista jonossa olevat viestit",
|
||||
"session.followupDock.expand": "Laajenna jonossa olevia viestejä",
|
||||
"session.revertDock.summary.one": "{{count}} palautettu viesti",
|
||||
"session.revertDock.summary.other": "{{count}} palautettua viestiä",
|
||||
"session.revertDock.collapse": "Kutista palautetut viestit",
|
||||
"session.revertDock.expand": "Laajenna palautetut viestit",
|
||||
"session.revertDock.restore": "Palauta viesti",
|
||||
"session.new.title": "Rakenna mitä tahansa",
|
||||
"session.new.project.new": "Uusi projekti",
|
||||
"session.new.project.search": "Etsi projekteja",
|
||||
@@ -678,7 +698,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Laajennukset",
|
||||
"status.popover.action.manageServers": "Hallinnoi palvelimia",
|
||||
"common.copied": "Kopioitu",
|
||||
"session.share.popover.title": "Julkaise verkossa",
|
||||
"session.share.popover.description.shared":
|
||||
"Tämä istunto on julkinen verkossa. Se on kaikkien linkin saaneiden käytettävissä.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Jaa istunto julkisesti verkossa. Se on kaikkien linkin saaneiden käytettävissä.",
|
||||
"session.share.action.share": "Jaa",
|
||||
"session.share.action.publish": "Julkaise",
|
||||
"session.share.action.publishing": "Julkaistaan...",
|
||||
"session.share.action.unpublish": "Peruuta julkaisu",
|
||||
"session.share.action.unpublishing": "Peruutetaan julkaisua...",
|
||||
"session.share.action.view": "Näytä",
|
||||
"session.share.copy.copied": "Kopioitu",
|
||||
"session.share.copy.copyLink": "Kopioi linkki",
|
||||
"lsp.tooltip.none": "Ei LSP-palvelimia",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Ladataan kehotetta...",
|
||||
@@ -809,6 +841,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Mukauta terminaalissa käytettävää fonttia",
|
||||
"settings.general.row.uiFont.title": "Käyttöliittymän fontti",
|
||||
"settings.general.row.uiFont.description": "Mukauta koko käyttöliittymässä käytettyä fonttia",
|
||||
"settings.general.row.followup.title": "Jatkokehotteiden toiminta",
|
||||
"settings.general.row.followup.description": "Valitse, ohjaavatko jatkokehotteet heti vai odottavatko ne jonossa",
|
||||
"settings.general.row.followup.option.queue": "Jonota",
|
||||
"settings.general.row.followup.option.steer": "Ohjaa heti",
|
||||
"settings.general.row.showFileTree.title": "Tiedostopuu",
|
||||
"settings.general.row.showFileTree.description": "Näytä tiedostopuupaneeli istunnoissa",
|
||||
"settings.general.row.showNavigation.title": "Navigointiohjaimet",
|
||||
|
||||
@@ -174,6 +174,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Samanber setuna fyri at minka um samanhangsstøddina",
|
||||
"command.session.fork": "Greina frá boðum",
|
||||
"command.session.fork.description": "Stovna eina nýggja setu frá einum undanfarnum boði",
|
||||
"command.session.share": "Deil setu",
|
||||
"command.session.share.description": "Deil hesa setuna og kopiera URL á útklippiborðið",
|
||||
"command.session.unshare": "Avdeila setu",
|
||||
"command.session.unshare.description": "Lat vera við at deila hesa setuna",
|
||||
"command.session.export": "Útflutningsløta",
|
||||
"command.session.export.description": "Eksportera alt setuuppskriftið sum JSON",
|
||||
"palette.search.placeholder": "Leita eftir fílum, skipanum og setum",
|
||||
@@ -588,6 +592,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Tað eydnaðist ikki at lista fílur",
|
||||
"toast.context.noLineSelection.title": "Einki linjuval",
|
||||
"toast.context.noLineSelection.description": "Vel eitt linjuøki í einum fíluflipa fyrst.",
|
||||
"toast.session.share.copyFailed.title": "Tað eydnaðist ikki at avrita URL til klippiborð",
|
||||
"toast.session.share.success.title": "Setan deild",
|
||||
"toast.session.share.success.description": "Deil URL avritað á klippiborð!",
|
||||
"toast.session.share.failed.title": "Tað eydnaðist ikki at deila setu",
|
||||
"toast.session.share.failed.description": "Ein feilur hendi, meðan setan varð deild.",
|
||||
"toast.session.unshare.success.title": "Setan ódeild",
|
||||
"toast.session.unshare.success.description": "Setan ódeild eydnaðist!",
|
||||
"toast.session.unshare.failed.title": "Tað eydnaðist ikki at avdeila setuna",
|
||||
@@ -719,6 +728,17 @@ export const dict = {
|
||||
"session.question.restore": "Endurnýggja spurningin",
|
||||
"session.question.pending.one": "{{count}} spurningur í bíðini",
|
||||
"session.question.pending.other": "{{count}} bíðandi spurningar",
|
||||
"session.followupDock.summary.one": "{{count}} bíðirøð boð",
|
||||
"session.followupDock.summary.other": "{{count}} bíðirøð boð",
|
||||
"session.followupDock.sendNow": "Send nú",
|
||||
"session.followupDock.edit": "Rætta",
|
||||
"session.followupDock.collapse": "Samla boð í bíðirøð",
|
||||
"session.followupDock.expand": "Víðka boð í bíðirøð",
|
||||
"session.revertDock.summary.one": "{{count}} rullað afturboð",
|
||||
"session.revertDock.summary.other": "{{count}} rullað boð aftur",
|
||||
"session.revertDock.collapse": "Kollaps afturrullað boð",
|
||||
"session.revertDock.expand": "Víðka afturrullað boð",
|
||||
"session.revertDock.restore": "Endurnýggja boð",
|
||||
"session.new.title": "Bygg alt",
|
||||
"session.new.project.new": "Nýggj verkætlan",
|
||||
"session.new.project.search": "Leita verkætlanir",
|
||||
@@ -765,7 +785,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Tilskot",
|
||||
"status.popover.action.manageServers": "Umsita ambætarar",
|
||||
"common.copied": "Avritað",
|
||||
"session.share.popover.title": "Útgeva á netinum",
|
||||
"session.share.popover.description.shared":
|
||||
"Henda setan er almenn á netinum. Tað er atkomiligt fyri øll, sum hava leinkjuna.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Deil setu alment á netinum. Tað verður atkomiligt fyri øll, sum hava leinkjuna.",
|
||||
"session.share.action.share": "Deil",
|
||||
"session.share.action.publish": "Útgeva",
|
||||
"session.share.action.publishing": "Útgáva...",
|
||||
"session.share.action.unpublish": "Fráútgeva",
|
||||
"session.share.action.unpublishing": "Avútgáva...",
|
||||
"session.share.action.view": "Vís",
|
||||
"session.share.copy.copied": "Avritað",
|
||||
"session.share.copy.copyLink": "Avrita leinkju",
|
||||
"lsp.tooltip.none": "Ongar LSP ambætarar",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Heinta boð...",
|
||||
@@ -893,6 +925,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Tillaga skriftslagið, sum verður brúkt í terminalinum",
|
||||
"settings.general.row.uiFont.title": "UI Skrift",
|
||||
"settings.general.row.uiFont.description": "Tillaga skriftslagið, sum verður brúkt í øllum nýtsluflatanum",
|
||||
"settings.general.row.followup.title": "Eftirfylgjandi atferð",
|
||||
"settings.general.row.followup.description": "Vel um eftirfylgjandi boðini stýra beinanvegin ella bíða í bíðirøð",
|
||||
"settings.general.row.followup.option.queue": "Bíðirøð",
|
||||
"settings.general.row.followup.option.steer": "Stýr",
|
||||
"settings.general.row.showFileTree.title": "Fílutræ",
|
||||
"settings.general.row.showFileTree.description": "Vís fílutræpanelið í setum",
|
||||
"settings.general.row.showNavigation.title": "Navigatiónsstýringar",
|
||||
|
||||
@@ -182,6 +182,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Résumer la session pour réduire la taille du contexte",
|
||||
"command.session.fork": "Bifurquer à partir du message",
|
||||
"command.session.fork.description": "Créer une nouvelle session à partir d'un message précédent",
|
||||
"command.session.share": "Partager la session",
|
||||
"command.session.share.description": "Partager cette session et copier l'URL dans le presse-papiers",
|
||||
"command.session.unshare": "Ne plus partager la session",
|
||||
"command.session.unshare.description": "Arrêter de partager cette session",
|
||||
"command.session.export": "Exporter la session",
|
||||
"command.session.export.description": "Exporter la transcription intégrale de la session au format JSON",
|
||||
|
||||
@@ -594,6 +598,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Échec de la liste des fichiers",
|
||||
"toast.context.noLineSelection.title": "Aucune sélection de lignes",
|
||||
"toast.context.noLineSelection.description": "Sélectionnez d'abord une plage de lignes dans un onglet de fichier.",
|
||||
"toast.session.share.copyFailed.title": "Échec de la copie de l'URL dans le presse-papiers",
|
||||
"toast.session.share.success.title": "Session partagée",
|
||||
"toast.session.share.success.description": "URL de partage copiée dans le presse-papiers !",
|
||||
"toast.session.share.failed.title": "Échec du partage de la session",
|
||||
"toast.session.share.failed.description": "Une erreur s'est produite lors du partage de la session",
|
||||
"toast.session.unshare.success.title": "Partage de la session désactivé",
|
||||
"toast.session.unshare.success.description": "Le partage de la session a bien été désactivé.",
|
||||
"toast.session.unshare.failed.title": "Échec de la désactivation du partage",
|
||||
@@ -717,6 +726,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} question en attente",
|
||||
"session.question.pending.many": "{{count}} de questions en attente",
|
||||
"session.question.pending.other": "{{count}} questions en attente",
|
||||
"session.followupDock.summary.one": "{{count}} message en file d'attente",
|
||||
"session.followupDock.summary.many": "{{count}} de messages en file d'attente",
|
||||
"session.followupDock.summary.other": "{{count}} messages en file d'attente",
|
||||
"session.followupDock.sendNow": "Envoyer maintenant",
|
||||
"session.followupDock.edit": "Modifier",
|
||||
"session.followupDock.collapse": "Réduire les messages en file d'attente",
|
||||
"session.followupDock.expand": "Développer les messages en file d'attente",
|
||||
"session.revertDock.summary.one": "{{count}} message annulé",
|
||||
"session.revertDock.summary.many": "{{count}} de messages annulés",
|
||||
"session.revertDock.summary.other": "{{count}} messages annulés",
|
||||
"session.revertDock.collapse": "Réduire les messages annulés",
|
||||
"session.revertDock.expand": "Développer les messages annulés",
|
||||
"session.revertDock.restore": "Restaurer le message",
|
||||
"session.new.title": "Créez ce que vous voulez",
|
||||
"session.new.project.new": "Nouveau projet",
|
||||
"session.new.project.search": "Rechercher des projets",
|
||||
@@ -744,7 +766,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Plugins",
|
||||
"status.popover.action.manageServers": "Gérer les serveurs",
|
||||
"common.copied": "Copié",
|
||||
"session.share.popover.title": "Publier sur le Web",
|
||||
"session.share.popover.description.shared":
|
||||
"Cette session est publique sur le Web. Elle est accessible à toute personne disposant du lien.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Rendre la session publique sur le Web. Elle sera accessible à toute personne disposant du lien.",
|
||||
"session.share.action.share": "Partager",
|
||||
"session.share.action.publish": "Publier",
|
||||
"session.share.action.publishing": "Publication...",
|
||||
"session.share.action.unpublish": "Dépublier",
|
||||
"session.share.action.unpublishing": "Dépublication...",
|
||||
"session.share.action.view": "Voir",
|
||||
"session.share.copy.copied": "Copié",
|
||||
"session.share.copy.copyLink": "Copier le lien",
|
||||
"lsp.tooltip.none": "Aucun serveur LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Chargement de l'invite...",
|
||||
@@ -819,6 +853,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal",
|
||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
||||
"settings.general.row.followup.title": "Comportement des messages de suivi",
|
||||
"settings.general.row.followup.description":
|
||||
"Choisissez si les messages de suivi orientent immédiatement l'agent ou sont placés dans une file d'attente",
|
||||
"settings.general.row.followup.option.queue": "File d'attente",
|
||||
"settings.general.row.followup.option.steer": "Orienter",
|
||||
"settings.general.row.showFileTree.title": "Arborescence des fichiers",
|
||||
"settings.general.row.showFileTree.description": "Afficher le panneau d'arborescence des fichiers dans les sessions",
|
||||
"settings.general.row.showNavigation.title": "Commandes de navigation",
|
||||
|
||||
@@ -175,6 +175,10 @@ 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,6 +589,11 @@ 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": "הקישור לשיתוף הועתק ללוח!",
|
||||
"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": "ביטול השיתוף של ההפעלה נכשל",
|
||||
@@ -724,6 +733,17 @@ 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": "חיפוש פרויקטים",
|
||||
@@ -770,7 +790,17 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "תוספים",
|
||||
"status.popover.action.manageServers": "ניהול שרתים",
|
||||
"common.copied": "הועתק",
|
||||
"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": "העתק קישור",
|
||||
"lsp.tooltip.none": "אין שרתי LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "טוען פרומפט...",
|
||||
@@ -927,6 +957,10 @@ 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": "בקרות ניווט",
|
||||
@@ -1165,6 +1199,8 @@ export const dict = {
|
||||
"workspace.reset.archived.many": "{{count}} הפעלות יועברו לארכיון.",
|
||||
"workspace.reset.note": "פעולה זו תאפס את סביבת העבודה כך שתתאים לענף ברירת המחדל.",
|
||||
"session.question.pending.two": "{{count}} שאלות ממתינות",
|
||||
"session.followupDock.summary.two": "{{count}} הודעות בתור",
|
||||
"session.revertDock.summary.two": "{{count}} הודעות בוטלו",
|
||||
"settings.workspaces.count.two": "{{count}} סביבות עבודה",
|
||||
"settings.workspaces.sessions.two": "{{count}} הפעלות ב-{{project}}",
|
||||
"session.background.shell.two": "{{count}} פקודות מעטפת",
|
||||
|
||||
@@ -181,6 +181,10 @@ 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 के रूप में निर्यात करें",
|
||||
|
||||
@@ -598,6 +602,11 @@ 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": "सेशन को अनशेयर करने में विफल",
|
||||
@@ -730,6 +739,17 @@ 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": "प्रोजेक्ट खोजें",
|
||||
@@ -776,7 +796,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "प्लग-इन",
|
||||
"status.popover.action.manageServers": "सर्वर प्रबंधित करें",
|
||||
"common.copied": "कॉपी किया गया",
|
||||
"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": "लिंक कॉपी करें",
|
||||
"lsp.tooltip.none": "कोई LSP सर्वर नहीं",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "प्रॉम्प्ट लोड हो रहा है...",
|
||||
@@ -903,6 +935,11 @@ 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": "नेविगेशन नियंत्रण",
|
||||
|
||||
@@ -178,6 +178,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Sažmite sesiju kako biste smanjili veličinu konteksta",
|
||||
"command.session.fork": "Odvoji od poruke",
|
||||
"command.session.fork.description": "Stvorite novu sesiju iz prethodne poruke",
|
||||
"command.session.share": "Podijeli sesiju",
|
||||
"command.session.share.description": "Podijelite ovu sesiju i kopirajte URL u međuspremnik",
|
||||
"command.session.unshare": "Poništi dijeljenje sesije",
|
||||
"command.session.unshare.description": "Prestani dijeliti ovu sesiju",
|
||||
"command.session.export": "Izvezi sesiju",
|
||||
"command.session.export.description": "Izvezite puni transkript sesije kao JSON",
|
||||
"palette.search.placeholder": "Pretraživanje datoteka, naredbi i sesija",
|
||||
@@ -594,6 +598,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Popis datoteka nije uspio",
|
||||
"toast.context.noLineSelection.title": "Bez odabira linije",
|
||||
"toast.context.noLineSelection.description": "Najprije odaberite raspon redaka na kartici datoteke.",
|
||||
"toast.session.share.copyFailed.title": "Kopiranje URL u međuspremnik nije uspjelo",
|
||||
"toast.session.share.success.title": "Sesija podijeljena",
|
||||
"toast.session.share.success.description": "Podijeli URL kopirano u međuspremnik!",
|
||||
"toast.session.share.failed.title": "Dijeljenje sesije nije uspjelo",
|
||||
"toast.session.share.failed.description": "Došlo je do pogreške prilikom dijeljenja sesije",
|
||||
"toast.session.unshare.success.title": "Dijeljenje sesije je prekinuto",
|
||||
"toast.session.unshare.success.description": "Dijeljenje sesije uspješno je prekinuto.",
|
||||
"toast.session.unshare.failed.title": "Poništavanje dijeljenja sesije nije uspjelo",
|
||||
@@ -727,6 +736,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
||||
"session.question.pending.other": "{{count}} pitanja na čekanju",
|
||||
"session.question.pending.few": "{{count}} pitanja na čekanju",
|
||||
"session.followupDock.summary.one": "{{count}} poruka u čekanju",
|
||||
"session.followupDock.summary.other": "{{count}} poruke u redu čekanja",
|
||||
"session.followupDock.summary.few": "{{count}} poruke u redu čekanja",
|
||||
"session.followupDock.sendNow": "Pošalji sada",
|
||||
"session.followupDock.edit": "Uredi",
|
||||
"session.followupDock.collapse": "Sažmi poruke u redu čekanja",
|
||||
"session.followupDock.expand": "Proširi poruke u čekanju",
|
||||
"session.revertDock.summary.one": "{{count}} vratio poruku",
|
||||
"session.revertDock.summary.other": "{{count}} je vratio poruke",
|
||||
"session.revertDock.summary.few": "{{count}} vraćene poruke",
|
||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||
"session.revertDock.restore": "Vrati poruku",
|
||||
"session.new.title": "Gradite bilo što",
|
||||
"session.new.project.new": "Novi projekt",
|
||||
"session.new.project.search": "Pretražite projekte",
|
||||
@@ -773,7 +795,17 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Dodaci",
|
||||
"status.popover.action.manageServers": "Upravljanje poslužiteljima",
|
||||
"common.copied": "Kopirano",
|
||||
"session.share.popover.title": "Objavi na webu",
|
||||
"session.share.popover.description.shared": "Ova je sesija javna na webu. Dostupan je svima s vezom.",
|
||||
"session.share.popover.description.unshared": "Podijelite sesiju javno na webu. Bit će dostupna svima s vezom.",
|
||||
"session.share.action.share": "Udio",
|
||||
"session.share.action.publish": "Objaviti",
|
||||
"session.share.action.publishing": "Objavljivanje...",
|
||||
"session.share.action.unpublish": "Poništi objavu",
|
||||
"session.share.action.unpublishing": "Poništavanje objave...",
|
||||
"session.share.action.view": "Prikaz",
|
||||
"session.share.copy.copied": "Kopirano",
|
||||
"session.share.copy.copyLink": "Kopiraj vezu",
|
||||
"lsp.tooltip.none": "Nema LSP poslužitelja",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Učitavanje upita...",
|
||||
@@ -903,6 +935,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"settings.general.row.uiFont.description": "Prilagodite font koji se koristi u cijelom sučelju",
|
||||
"settings.general.row.followup.title": "Praćenje ponašanja",
|
||||
"settings.general.row.followup.description":
|
||||
"Odaberite hoće li se naknadni upiti usmjeravati odmah ili čekati u redu",
|
||||
"settings.general.row.followup.option.queue": "Red",
|
||||
"settings.general.row.followup.option.steer": "Upravljati",
|
||||
"settings.general.row.showFileTree.title": "Stablo datoteka",
|
||||
"settings.general.row.showFileTree.description": "Prikaži ploču stabla datoteka u sesijama",
|
||||
"settings.general.row.showNavigation.title": "Navigacijske kontrole",
|
||||
|
||||
@@ -178,6 +178,10 @@ export const dict = {
|
||||
"command.session.compact.description": "A kontextus méretének csökkentése érdekében foglalja össze a munkamenetet",
|
||||
"command.session.fork": "Elágazás az üzenettől",
|
||||
"command.session.fork.description": "Hozzon létre új munkamenetet egy korábbi üzenetből",
|
||||
"command.session.share": "Munkamenet megosztása",
|
||||
"command.session.share.description": "Ossza meg ezt a munkamenetet, és másolja a URL fájlt a vágólapra",
|
||||
"command.session.unshare": "Munkamenet megosztásának megszüntetése",
|
||||
"command.session.unshare.description": "Állítsa le a munkamenet megosztását",
|
||||
"command.session.export": "Exportálási munkamenet",
|
||||
"command.session.export.description": "Exportálja a teljes munkamenet-átiratot JSON formátumban",
|
||||
"palette.search.placeholder": "Fájlok, parancsok és munkamenetek keresése",
|
||||
@@ -593,6 +597,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Nem sikerült listázni a fájlokat",
|
||||
"toast.context.noLineSelection.title": "Nincs vonalválasztás",
|
||||
"toast.context.noLineSelection.description": "Először válasszon ki egy sortartományt egy fájl lapon.",
|
||||
"toast.session.share.copyFailed.title": "Nem sikerült a URL vágólapra másolása",
|
||||
"toast.session.share.success.title": "Munkamenet megosztva",
|
||||
"toast.session.share.success.description": "Megosztás URL vágólapra másolva!",
|
||||
"toast.session.share.failed.title": "Nem sikerült megosztani a munkamenetet",
|
||||
"toast.session.share.failed.description": "Hiba történt a munkamenet megosztása közben",
|
||||
"toast.session.unshare.success.title": "Munkamenet megosztása visszavonva",
|
||||
"toast.session.unshare.success.description": "A munkamenet megosztása sikeresen visszavonva!",
|
||||
"toast.session.unshare.failed.title": "Nem sikerült megszüntetni a munkamenet megosztását",
|
||||
@@ -726,6 +735,17 @@ export const dict = {
|
||||
"session.question.restore": "Kérdés visszaállítása",
|
||||
"session.question.pending.one": "{{count}} függőben lévő kérdés",
|
||||
"session.question.pending.other": "{{count}} függőben lévő kérdések",
|
||||
"session.followupDock.summary.one": "{{count}} sorba állított üzenet",
|
||||
"session.followupDock.summary.other": "{{count}} sorba állított üzenetek",
|
||||
"session.followupDock.sendNow": "Küldje el most",
|
||||
"session.followupDock.edit": "Szerkesztés",
|
||||
"session.followupDock.collapse": "A sorba állított üzenetek összecsukása",
|
||||
"session.followupDock.expand": "A sorban álló üzenetek kibontása",
|
||||
"session.revertDock.summary.one": "{{count}} visszavont üzenet",
|
||||
"session.revertDock.summary.other": "{{count}} visszavont üzenetek",
|
||||
"session.revertDock.collapse": "A visszagörgetett üzenetek összecsukása",
|
||||
"session.revertDock.expand": "A visszagörgetett üzenetek kibontása",
|
||||
"session.revertDock.restore": "Üzenet visszaállítása",
|
||||
"session.new.title": "Építsen bármit",
|
||||
"session.new.project.new": "Új projekt",
|
||||
"session.new.project.search": "Projektek keresése",
|
||||
@@ -772,7 +792,18 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Beépülő modulok",
|
||||
"status.popover.action.manageServers": "Szerverek kezelése",
|
||||
"common.copied": "Másolva",
|
||||
"session.share.popover.title": "Közzététel a weben",
|
||||
"session.share.popover.description.shared": "Ez a munkamenet nyilvános a weben. A link birtokában bárki hozzáférhet.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Ossza meg a munkamenetet nyilvánosan az interneten. A link birtokában bárki számára elérhető lesz.",
|
||||
"session.share.action.share": "Részesedés",
|
||||
"session.share.action.publish": "Közzététel",
|
||||
"session.share.action.publishing": "Kiadás...",
|
||||
"session.share.action.unpublish": "Közzététel visszavonása",
|
||||
"session.share.action.unpublishing": "Közzététel visszavonása...",
|
||||
"session.share.action.view": "Nézet",
|
||||
"session.share.copy.copied": "Másolva",
|
||||
"session.share.copy.copyLink": "Link másolása",
|
||||
"lsp.tooltip.none": "Nincsenek LSP szerverek",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Prompt betöltése...",
|
||||
@@ -904,6 +935,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Testreszabhatja a terminálban használt betűtípust",
|
||||
"settings.general.row.uiFont.title": "UI betűtípus",
|
||||
"settings.general.row.uiFont.description": "Testreszabhatja a felületen használt betűtípust",
|
||||
"settings.general.row.followup.title": "Nyomon követési viselkedés",
|
||||
"settings.general.row.followup.description":
|
||||
"Válassza ki, hogy a nyomon követési felszólítások azonnal irányítsák, vagy várakozzanak a sorban",
|
||||
"settings.general.row.followup.option.queue": "Sor",
|
||||
"settings.general.row.followup.option.steer": "Tinó",
|
||||
"settings.general.row.showFileTree.title": "Fájlfa",
|
||||
"settings.general.row.showFileTree.description": "A fájlfa panel megjelenítése a munkamenetekben",
|
||||
"settings.general.row.showNavigation.title": "Navigációs vezérlők",
|
||||
|
||||
@@ -176,6 +176,10 @@ 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": "Որոնել ֆայլեր, հրամաններ և նիստեր",
|
||||
@@ -592,6 +596,11 @@ 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": "Չհաջողվեց դադարեցնել նիստի համօգտագործումը",
|
||||
@@ -724,6 +733,17 @@ 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": "Որոնել նախագծեր",
|
||||
@@ -770,7 +790,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Ընդլայնումներ",
|
||||
"status.popover.action.manageServers": "Կառավարել սերվերները",
|
||||
"common.copied": "Պատճենված",
|
||||
"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": "Պատճենել հղումը",
|
||||
"lsp.tooltip.none": "Չկան LSP սերվեր",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Բեռնվում է հուշում...",
|
||||
@@ -901,6 +933,10 @@ 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": "Նավարկության կառավարում",
|
||||
|
||||
@@ -188,6 +188,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Ringkas sesi untuk mengurangi ukuran konteks",
|
||||
"command.session.fork": "Fork dari pesan",
|
||||
"command.session.fork.description": "Buat sesi baru dari pesan sebelumnya",
|
||||
"command.session.share": "Bagikan sesi",
|
||||
"command.session.share.description": "Bagikan sesi ini dan salin URL ke papan klip",
|
||||
"command.session.unshare": "Hentikan berbagi",
|
||||
"command.session.unshare.description": "Hentikan berbagi sesi ini",
|
||||
|
||||
"command.session.export": "Ekspor sesi",
|
||||
"command.session.export.description": "Ekspor transkrip sesi lengkap sebagai JSON",
|
||||
@@ -642,6 +646,11 @@ export const dict = {
|
||||
"toast.context.noLineSelection.title": "Tidak ada pilihan baris",
|
||||
"toast.context.noLineSelection.description": "Pilih rentang baris di tab berkas terlebih dahulu.",
|
||||
|
||||
"toast.session.share.copyFailed.title": "Gagal menyalin URL ke papan klip",
|
||||
"toast.session.share.success.title": "Sesi dibagikan",
|
||||
"toast.session.share.success.description": "URL berbagi disalin ke papan klip!",
|
||||
"toast.session.share.failed.title": "Gagal membagikan sesi",
|
||||
"toast.session.share.failed.description": "Terjadi kesalahan saat membagikan sesi",
|
||||
|
||||
"toast.session.unshare.success.title": "Berbagi sesi dihentikan",
|
||||
"toast.session.unshare.success.description": "Berbagi sesi berhasil dihentikan!",
|
||||
@@ -788,6 +797,17 @@ export const dict = {
|
||||
"session.question.restore": "Pulihkan pertanyaan",
|
||||
"session.question.pending.one": "{{count}} pertanyaan tertunda",
|
||||
"session.question.pending.other": "{{count}} pertanyaan tertunda",
|
||||
"session.followupDock.summary.one": "{{count}} pesan dalam antrean",
|
||||
"session.followupDock.summary.other": "{{count}} pesan dalam antrean",
|
||||
"session.followupDock.sendNow": "Kirim sekarang",
|
||||
"session.followupDock.edit": "Sunting",
|
||||
"session.followupDock.collapse": "Ciutkan pesan dalam antrean",
|
||||
"session.followupDock.expand": "Bentangkan pesan dalam antrean",
|
||||
"session.revertDock.summary.one": "{{count}} pesan diurungkan",
|
||||
"session.revertDock.summary.other": "{{count}} pesan diurungkan",
|
||||
"session.revertDock.collapse": "Ciutkan pesan yang diurungkan",
|
||||
"session.revertDock.expand": "Bentangkan pesan yang diurungkan",
|
||||
"session.revertDock.restore": "Pulihkan pesan",
|
||||
|
||||
"session.new.title": "Buat apa saja",
|
||||
"session.new.project.new": "Proyek baru",
|
||||
@@ -838,7 +858,18 @@ export const dict = {
|
||||
"status.popover.tab.plugins": "Plugin",
|
||||
"status.popover.action.manageServers": "Kelola server",
|
||||
|
||||
"common.copied": "Tersalin",
|
||||
"session.share.popover.title": "Publikasikan di web",
|
||||
"session.share.popover.description.shared": "Sesi ini publik di web. Siapa pun dengan tautan dapat mengaksesnya.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Bagikan sesi secara publik di web. Siapa pun dengan tautan dapat mengaksesnya.",
|
||||
"session.share.action.share": "Bagikan",
|
||||
"session.share.action.publish": "Publikasikan",
|
||||
"session.share.action.publishing": "Mempublikasikan...",
|
||||
"session.share.action.unpublish": "Batalkan publikasi",
|
||||
"session.share.action.unpublishing": "Membatalkan publikasi...",
|
||||
"session.share.action.view": "Lihat",
|
||||
"session.share.copy.copied": "Tersalin",
|
||||
"session.share.copy.copyLink": "Salin tautan",
|
||||
|
||||
"lsp.tooltip.none": "Tidak ada server LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
@@ -976,6 +1007,11 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Sesuaikan font yang digunakan di terminal",
|
||||
"settings.general.row.uiFont.title": "Font UI",
|
||||
"settings.general.row.uiFont.description": "Sesuaikan font yang digunakan di seluruh antarmuka",
|
||||
"settings.general.row.followup.title": "Perilaku lanjutan",
|
||||
"settings.general.row.followup.description":
|
||||
"Pilih apakah prompt lanjutan segera mengarahkan atau menunggu dalam antrean",
|
||||
"settings.general.row.followup.option.queue": "Antrean",
|
||||
"settings.general.row.followup.option.steer": "Arahkan",
|
||||
"settings.general.row.showFileTree.title": "Pohon berkas",
|
||||
"settings.general.row.showFileTree.description": "Tampilkan panel pohon berkas di sesi",
|
||||
"settings.general.row.showNavigation.title": "Kontrol navigasi",
|
||||
|
||||
@@ -178,6 +178,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Draga setuna saman til að minnka samhengi",
|
||||
"command.session.fork": "Kvíslast frá skilaboðum",
|
||||
"command.session.fork.description": "Stofna nýja setu út frá fyrri skilaboðum",
|
||||
"command.session.share": "Deila setu",
|
||||
"command.session.share.description": "Deila þessari setu og afrita slóðina á klippispjaldið",
|
||||
"command.session.unshare": "Hætta að deila setu",
|
||||
"command.session.unshare.description": "Hætta að deila þessari setu",
|
||||
"command.session.export": "Flytja út setu",
|
||||
"command.session.export.description": "Flytja allt setuafritið út sem JSON",
|
||||
"palette.search.placeholder": "Leitaðu að skrám, skipunum og fundum",
|
||||
@@ -593,6 +597,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Mistókst að skrá skrár",
|
||||
"toast.context.noLineSelection.title": "Ekkert línuval",
|
||||
"toast.context.noLineSelection.description": "Veldu línusvið í skráarflipa fyrst.",
|
||||
"toast.session.share.copyFailed.title": "Mistókst að afrita URL á klemmuspjald",
|
||||
"toast.session.share.success.title": "Fundi deilt",
|
||||
"toast.session.share.success.description": "Deildu URL afritað á klemmuspjald!",
|
||||
"toast.session.share.failed.title": "Ekki tókst að deila setu",
|
||||
"toast.session.share.failed.description": "Villa kom upp þegar lotunni var deilt",
|
||||
"toast.session.unshare.success.title": "Ekki deilt lotu",
|
||||
"toast.session.unshare.success.description": "Lokað var við deilingu lotunnar!",
|
||||
"toast.session.unshare.failed.title": "Mistókst að hætta við deilingu lotu",
|
||||
@@ -723,6 +732,17 @@ export const dict = {
|
||||
"session.question.restore": "Endurheimta spurningu",
|
||||
"session.question.pending.one": "{{count}} spurning í bið",
|
||||
"session.question.pending.other": "{{count}} spurningar í bið",
|
||||
"session.followupDock.summary.one": "{{count}} skilaboð í biðröð",
|
||||
"session.followupDock.summary.other": "{{count}} skilaboð í biðröð",
|
||||
"session.followupDock.sendNow": "Sendu núna",
|
||||
"session.followupDock.edit": "Breyta",
|
||||
"session.followupDock.collapse": "Draga saman skilaboð í biðröð",
|
||||
"session.followupDock.expand": "Stækkaðu skilaboð í biðröð",
|
||||
"session.revertDock.summary.one": "{{count}} afturkallað skilaboð",
|
||||
"session.revertDock.summary.other": "{{count}} afturkallað skilaboð",
|
||||
"session.revertDock.collapse": "Dragðu saman skilaboð sem eru afturkölluð",
|
||||
"session.revertDock.expand": "Stækka afturkölluð skilaboð",
|
||||
"session.revertDock.restore": "Endurheimta skilaboð",
|
||||
"session.new.title": "Byggja hvað sem er",
|
||||
"session.new.project.new": "Nýtt verkefni",
|
||||
"session.new.project.search": "Leita að verkefnum",
|
||||
@@ -769,7 +789,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Viðbætur",
|
||||
"status.popover.action.manageServers": "Stjórna netþjónum",
|
||||
"common.copied": "Afritað",
|
||||
"session.share.popover.title": "Birta á vefnum",
|
||||
"session.share.popover.description.shared":
|
||||
"Þessi seta er opinber á vefnum. Allir sem hafa tengilinn geta opnað hana.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Deildu setunni opinberlega á vefnum. Allir sem hafa tengilinn geta opnað hana.",
|
||||
"session.share.action.share": "Deila",
|
||||
"session.share.action.publish": "Birta",
|
||||
"session.share.action.publishing": "Birtir...",
|
||||
"session.share.action.unpublish": "Afbirta",
|
||||
"session.share.action.unpublishing": "Hættir að birta...",
|
||||
"session.share.action.view": "Skoða",
|
||||
"session.share.copy.copied": "Afritað",
|
||||
"session.share.copy.copyLink": "Afritaðu tengil",
|
||||
"lsp.tooltip.none": "Engir LSP netþjónar",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Hleður tilkynningu...",
|
||||
@@ -897,6 +929,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Sérsníddu leturgerðina sem notuð er í flugstöðinni",
|
||||
"settings.general.row.uiFont.title": "UI leturgerð",
|
||||
"settings.general.row.uiFont.description": "Sérsníddu leturgerðina sem notuð er í öllu viðmótinu",
|
||||
"settings.general.row.followup.title": "Eftirfylgnihegðun",
|
||||
"settings.general.row.followup.description": "Veldu hvort eftirfylgnibeiðnir stýra strax eða bíða í biðröð",
|
||||
"settings.general.row.followup.option.queue": "Biðröð",
|
||||
"settings.general.row.followup.option.steer": "Stýra",
|
||||
"settings.general.row.showFileTree.title": "Skráartré",
|
||||
"settings.general.row.showFileTree.description": "Sýndu skráartréspjaldið í lotum",
|
||||
"settings.general.row.showNavigation.title": "Leiðsögustýringar",
|
||||
|
||||
@@ -82,6 +82,10 @@ export const dict = {
|
||||
"command.session.compact.description": "Riepiloga la sessione per ridurre le dimensioni del contesto",
|
||||
"command.session.fork": "Crea sessione dal messaggio",
|
||||
"command.session.fork.description": "Crea una nuova sessione da un messaggio precedente",
|
||||
"command.session.share": "Condividi sessione",
|
||||
"command.session.share.description": "Condividi questa sessione e copia l'URL negli appunti",
|
||||
"command.session.unshare": "Annulla condivisione sessione",
|
||||
"command.session.unshare.description": "Interrompi la condivisione di questa sessione",
|
||||
"command.session.export": "Esporta sessione",
|
||||
"command.session.export.description": "Esporta la trascrizione completa della sessione in formato JSON",
|
||||
|
||||
@@ -502,6 +506,11 @@ export const dict = {
|
||||
"toast.file.listFailed.title": "Impossibile elencare i file",
|
||||
"toast.context.noLineSelection.title": "Nessuna riga selezionata",
|
||||
"toast.context.noLineSelection.description": "Selezionare prima un intervallo di righe nella scheda di un file.",
|
||||
"toast.session.share.copyFailed.title": "Impossibile copiare l'URL negli appunti",
|
||||
"toast.session.share.success.title": "Sessione condivisa",
|
||||
"toast.session.share.success.description": "URL di condivisione copiato negli appunti",
|
||||
"toast.session.share.failed.title": "Impossibile condividere la sessione",
|
||||
"toast.session.share.failed.description": "Si è verificato un errore durante la condivisione della sessione",
|
||||
"toast.session.unshare.success.title": "Sessione non condivisa",
|
||||
"toast.session.unshare.success.description": "Condivisione della sessione annullata!",
|
||||
"toast.session.unshare.failed.title": "Impossibile annullare la condivisione della sessione",
|
||||
@@ -636,6 +645,19 @@ export const dict = {
|
||||
"session.question.pending.one": "{{count}} domanda in sospeso",
|
||||
"session.question.pending.many": "{{count}} di domande in sospeso",
|
||||
"session.question.pending.other": "{{count}} domande in sospeso",
|
||||
"session.followupDock.summary.one": "{{count}} messaggio in coda",
|
||||
"session.followupDock.summary.many": "{{count}} di messaggi in coda",
|
||||
"session.followupDock.summary.other": "{{count}} messaggi in coda",
|
||||
"session.followupDock.sendNow": "Invia ora",
|
||||
"session.followupDock.edit": "Modifica",
|
||||
"session.followupDock.collapse": "Comprimi i messaggi in coda",
|
||||
"session.followupDock.expand": "Espandi i messaggi in coda",
|
||||
"session.revertDock.summary.one": "{{count}} messaggio annullato",
|
||||
"session.revertDock.summary.many": "{{count}} di messaggi annullati",
|
||||
"session.revertDock.summary.other": "{{count}} messaggi annullati",
|
||||
"session.revertDock.collapse": "Comprimi i messaggi annullati",
|
||||
"session.revertDock.expand": "Espandi i messaggi annullati",
|
||||
"session.revertDock.restore": "Ripristina il messaggio",
|
||||
"session.new.title": "Crea qualsiasi cosa",
|
||||
"session.new.project.new": "Nuovo progetto",
|
||||
"session.new.project.search": "Cerca progetti",
|
||||
@@ -682,7 +704,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Plugin",
|
||||
"status.popover.action.manageServers": "Gestisci server",
|
||||
"common.copied": "Copiato",
|
||||
"session.share.popover.title": "Pubblica sul web",
|
||||
"session.share.popover.description.shared":
|
||||
"Questa sessione è pubblica sul web. È accessibile a chiunque abbia il collegamento.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Condividi la sessione pubblicamente sul web. Sarà accessibile a chiunque abbia il collegamento.",
|
||||
"session.share.action.share": "Condividi",
|
||||
"session.share.action.publish": "Pubblica",
|
||||
"session.share.action.publishing": "Pubblicazione...",
|
||||
"session.share.action.unpublish": "Annulla pubblicazione",
|
||||
"session.share.action.unpublishing": "Annullamento della pubblicazione...",
|
||||
"session.share.action.view": "Visualizza",
|
||||
"session.share.copy.copied": "Copiato",
|
||||
"session.share.copy.copyLink": "Copia collegamento",
|
||||
"lsp.tooltip.none": "Nessun server LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "Caricamento del prompt...",
|
||||
@@ -812,6 +846,10 @@ export const dict = {
|
||||
"settings.general.row.terminalFont.description": "Personalizza il carattere utilizzato nel terminale",
|
||||
"settings.general.row.uiFont.title": "Carattere dell'interfaccia utente",
|
||||
"settings.general.row.uiFont.description": "Personalizza il carattere utilizzato nell'interfaccia",
|
||||
"settings.general.row.followup.title": "Gestione dei prompt successivi",
|
||||
"settings.general.row.followup.description": "Scegli se i prompt successivi intervengono subito o attendono in coda",
|
||||
"settings.general.row.followup.option.queue": "Coda",
|
||||
"settings.general.row.followup.option.steer": "Intervieni subito",
|
||||
"settings.general.row.showFileTree.title": "Albero dei file",
|
||||
"settings.general.row.showFileTree.description": "Mostra il pannello dell'albero dei file nelle sessioni",
|
||||
"settings.general.row.showNavigation.title": "Controlli di navigazione",
|
||||
|
||||
@@ -180,6 +180,10 @@ 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 としてエクスポート",
|
||||
|
||||
@@ -587,6 +591,11 @@ 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": "セッションの共有解除に失敗しました",
|
||||
@@ -702,6 +711,17 @@ 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": "プロジェクトを検索",
|
||||
@@ -729,7 +749,19 @@ export const dict = {
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "プラグイン",
|
||||
"status.popover.action.manageServers": "サーバーを管理",
|
||||
"common.copied": "コピーしました",
|
||||
"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": "リンクをコピー",
|
||||
"lsp.tooltip.none": "LSPサーバーなし",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
"prompt.loading": "プロンプトを読み込み中...",
|
||||
@@ -802,6 +834,11 @@ 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": "即座に実行 (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
Reference in New Issue
Block a user