mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-22 16:47:34 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3178cf10dd | ||
|
|
dcfe1ec7bd | ||
|
|
ceace24a3e | ||
|
|
19e1357a06 | ||
|
|
4b381ac6a1 |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
|
||||
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
|
||||
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
|
||||
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
|
||||
"x86_64-linux": "sha256-8hc0Typ9cA1NDpToM0Pq7q3AutSp+I80Sakthq10F4c=",
|
||||
"aarch64-linux": "sha256-7bzI4zWOdxuoMdMHMuqIAOgnuzWiHmpdCMYCYPbs+3c=",
|
||||
"aarch64-darwin": "sha256-EWDHUSVH2AjsNvoKvzC392H6GjCWVh07cOg2x0mAsng=",
|
||||
"x86_64-darwin": "sha256-7b2BRdRRVG+PMCSd/cm4E+slziwWIKLVttYkotdSrK4="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/BtwSidebar"
|
||||
const projectID = "proj_btw_sidebar"
|
||||
const sessionID = "ses_btw_sidebar"
|
||||
const otherSessionID = "ses_btw_sidebar_other"
|
||||
const title = "Side question session"
|
||||
const otherTitle = "Other side question session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
const prompts: unknown[] = []
|
||||
const generated = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "btw-sidebar",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: otherSessionID,
|
||||
slug: otherSessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title: otherTitle,
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsDiff: [],
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: async (input) => {
|
||||
generations.push(input)
|
||||
if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." }
|
||||
await generated.promise
|
||||
return {
|
||||
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, otherSessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "session", server, sessionId: otherSessionID },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, otherSessionID },
|
||||
)
|
||||
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await expectSessionTitle(page, title)
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
await editor.fill("/btw")
|
||||
const suggestion = page.locator('[data-suggestion-id="session.btw"]')
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/btw ")
|
||||
await editor.press("Enter")
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
|
||||
expect(generations).toEqual([])
|
||||
expect(prompts).toEqual([])
|
||||
|
||||
await editor.fill("/btw how does the retry loop work?")
|
||||
await editor.press("Enter")
|
||||
|
||||
const tab = page.getByRole("tab", { name: "/btw" })
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(tab).toHaveAttribute("data-selected", "")
|
||||
generated.resolve()
|
||||
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
|
||||
expect(generations).toHaveLength(1)
|
||||
expect(generations[0]?.sessionID).toBe(sessionID)
|
||||
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
|
||||
expect(prompts).toEqual([])
|
||||
await expect(editor).toHaveText("")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click()
|
||||
await expectSessionTitle(page, otherTitle)
|
||||
await editor.fill("/btw what belongs here?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0)
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -95,7 +97,7 @@ test("renames and closes the session tab from its context menu", async ({ page }
|
||||
await expect(tab).toBeFocused()
|
||||
await tab.press("Shift+F10")
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed from tab")
|
||||
await input.press("Enter")
|
||||
@@ -112,6 +114,28 @@ test("renames and closes the session tab from its context menu", async ({ page }
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("pastes rich text into the session tab title as plain text", async ({ page }) => {
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
|
||||
await expect(input).toBeFocused()
|
||||
await page.evaluate(async () => {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob(['<span style="font-size: 48px">Rich title</span>'], { type: "text/html" }),
|
||||
"text/plain": new Blob(["Rich title"], { type: "text/plain" }),
|
||||
}),
|
||||
])
|
||||
})
|
||||
await input.press("ControlOrMeta+A")
|
||||
await input.press("ControlOrMeta+V")
|
||||
await expect(input).toHaveText("Rich title")
|
||||
await expect(input.locator("*")).toHaveCount(0)
|
||||
await input.press("Enter")
|
||||
await expect(page.getByRole("heading", { name: "Rich title", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames an inactive tab without switching sessions", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
@@ -119,7 +143,7 @@ test("renames an inactive tab without switching sessions", async ({ page }) => {
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Inactive tab renamed")
|
||||
await input.press("Tab")
|
||||
|
||||
@@ -197,6 +197,13 @@ const Group = HttpApiGroup.make("mock")
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionGenerate", "/api/session/:sessionID/generate", {
|
||||
params: SessionParams,
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
|
||||
params: SessionParams,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface MockServerConfig {
|
||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
inbox?: unknown[] | (() => unknown[])
|
||||
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
|
||||
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
|
||||
}
|
||||
|
||||
@@ -456,6 +457,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
},
|
||||
}
|
||||
}),
|
||||
sessionGenerate: (ctx) =>
|
||||
Effect.promise(async () => ({
|
||||
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
|
||||
text: "Side-question answer",
|
||||
},
|
||||
})),
|
||||
sessionInboxCancel: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseClientSlashCommand } from "./client-slash-command"
|
||||
|
||||
const options = [
|
||||
{ id: "session.btw", trigger: "btw", arguments: true, type: "builtin" as const },
|
||||
{ id: "custom.btw", trigger: "custom", type: "custom" as const },
|
||||
{ id: "model.choose", trigger: "model", type: "builtin" as const },
|
||||
]
|
||||
|
||||
describe("parseClientSlashCommand", () => {
|
||||
test("parses inline and multiline arguments", () => {
|
||||
expect(parseClientSlashCommand(options, "/btw why this approach?")).toEqual({
|
||||
id: "session.btw",
|
||||
input: "why this approach?",
|
||||
})
|
||||
expect(parseClientSlashCommand(options, "/btw\nwhy this approach?")).toEqual({
|
||||
id: "session.btw",
|
||||
input: "why this approach?",
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts a bare argument command", () => {
|
||||
expect(parseClientSlashCommand(options, "/btw")).toEqual({ id: "session.btw", input: "" })
|
||||
})
|
||||
|
||||
test("rejects prefixes, custom commands, and ordinary slash commands", () => {
|
||||
expect(parseClientSlashCommand(options, "/btwx nope")).toBeUndefined()
|
||||
expect(parseClientSlashCommand(options, "/custom nope")).toBeUndefined()
|
||||
expect(parseClientSlashCommand(options, "/model opus")).toBeUndefined()
|
||||
expect(parseClientSlashCommand(options, "ask /btw later")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
type ClientSlashCommand = {
|
||||
id: string
|
||||
trigger: string
|
||||
arguments?: boolean
|
||||
type: "builtin" | "custom"
|
||||
}
|
||||
|
||||
export function parseSlashCommand(text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const separator = text.search(/\s/)
|
||||
const name = text.slice(1, separator === -1 ? undefined : separator)
|
||||
return { name, input: separator === -1 ? "" : text.slice(separator).trim() }
|
||||
}
|
||||
|
||||
export function parseClientSlashCommand(options: readonly ClientSlashCommand[], text: string) {
|
||||
const command = parseSlashCommand(text)
|
||||
if (!command) return
|
||||
const option = options.find((item) => item.type === "builtin" && item.arguments && item.trigger === command.name)
|
||||
if (!option) return
|
||||
return {
|
||||
id: option.id,
|
||||
input: command.input,
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { useAttachmentDestination } from "./attachments/destination"
|
||||
import { parseClientSlashCommand } from "./client-slash-command"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -73,9 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter(isAttachment),
|
||||
)
|
||||
const attachments = createMemo(() => prompt.current().filter(isAttachment))
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
@@ -242,6 +241,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
trigger: item.slash!,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
arguments: item.slashArguments,
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
@@ -299,6 +299,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
clear: comments.clear,
|
||||
restore: restoreHistoryComments,
|
||||
},
|
||||
clientCommand: (text) => {
|
||||
const selected = parseClientSlashCommand(slashCommands(), text)
|
||||
if (!selected) return
|
||||
return () => command.trigger(selected.id, "slash", selected.input)
|
||||
},
|
||||
})
|
||||
const controller = createComposerEditor({
|
||||
store: prompt.store,
|
||||
@@ -340,6 +345,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
if (item.kind !== "command") return
|
||||
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||
if (!selected || selected.type === "custom") return
|
||||
if (selected.arguments) return
|
||||
return () => command.trigger(selected.id, "slash")
|
||||
},
|
||||
attachments: {
|
||||
|
||||
@@ -54,14 +54,17 @@ function submitInput(
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
clientCommand,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
addToHistory: (prompt) =>
|
||||
history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
removeFromHistory: (prompt) =>
|
||||
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
resetHistory() {},
|
||||
@@ -118,6 +121,61 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("runs a client argument command without admitting it to the session", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
{ type: "text", content: "/btw why this approach?", start: 0, end: 23 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "diagram.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
])
|
||||
state.context.add({ type: "file", path: "src/retry.ts" })
|
||||
const calls: string[] = []
|
||||
const target = session({
|
||||
calls,
|
||||
prompt: async () => {
|
||||
throw new Error("client command must not call prompt")
|
||||
},
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
const history: string[] = []
|
||||
await submitInput(adapter, undefined, "normal", undefined, history, (text) => {
|
||||
expect(text).toBe("/btw why this approach?")
|
||||
return () => {
|
||||
calls.push("btw")
|
||||
}
|
||||
}).submit(new Event("submit"))
|
||||
|
||||
expect(calls).toEqual(["btw"])
|
||||
expect(history).toEqual([])
|
||||
expect(state.current()).toEqual([
|
||||
{ type: "text", content: "", start: 0, end: 0 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "diagram.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
])
|
||||
expect(state.context.items()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
@@ -625,12 +683,7 @@ describe("Composer submission", () => {
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => catalog,
|
||||
).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
|
||||
@@ -11,6 +11,7 @@ import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { parseSlashCommand } from "./client-slash-command"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
|
||||
@@ -37,6 +38,7 @@ type ComposerSubmitInput = {
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
delivery?: (alternate: boolean) => ComposerDelivery
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
|
||||
@@ -52,15 +54,31 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
|
||||
event.preventDefault()
|
||||
|
||||
const prompt = clonePrompt(input.adapter.state.current())
|
||||
const text = submissionText(prompt)
|
||||
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
|
||||
if (clientCommand) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
try {
|
||||
clearClientCommand(input, prompt)
|
||||
await clientCommand()
|
||||
} catch (error) {
|
||||
input.notify.failed("command", error)
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
return
|
||||
}
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt: clonePrompt(input.adapter.state.current()),
|
||||
prompt,
|
||||
context: input.adapter.state.context.items().map((item) => ({
|
||||
...item,
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
@@ -150,6 +168,17 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
|
||||
input.adapter.state.set([{ type: "text", content: "", start: 0, end: 0 }, ...prompt.filter(isAttachment)], 0)
|
||||
input.adapter.state.mode.set("normal")
|
||||
input.setMode("normal")
|
||||
input.closePopover()
|
||||
}
|
||||
|
||||
function submissionText(prompt: Prompt) {
|
||||
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
}
|
||||
|
||||
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
return {
|
||||
id: value.id,
|
||||
@@ -193,9 +222,9 @@ function readSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
prompt: Prompt,
|
||||
context: ComposerSubmission["context"],
|
||||
text: string,
|
||||
alternate: boolean,
|
||||
): 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")
|
||||
@@ -298,14 +327,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
}
|
||||
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!commands?.some((item) => item.name === command)) return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
const parsed = parseSlashCommand(text)
|
||||
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
|
||||
return { command: parsed.name, arguments: parsed.input }
|
||||
}
|
||||
|
||||
|
||||
async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
@@ -390,7 +416,10 @@ async function sendPrompt(
|
||||
|
||||
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) })),
|
||||
value.images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
return buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
|
||||
@@ -47,7 +47,7 @@ export function HomeCommandPalette(props: {
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
void item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
|
||||
@@ -151,6 +151,8 @@ 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.btw": "Ask a side question",
|
||||
"command.session.btw.description": "Get a one-shot answer without adding to the conversation",
|
||||
"command.session.export": "Export session",
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
"command.session.import": "Import session",
|
||||
@@ -756,6 +758,7 @@ export const dict = {
|
||||
"session.tab.browser": "Browser",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.btw": "/btw",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
@@ -933,6 +936,10 @@ export const dict = {
|
||||
"common.dismiss": "Dismiss",
|
||||
"common.moreCountSuffix": " (+{{count}} more)",
|
||||
"common.requestFailed": "Request failed",
|
||||
"session.btw.questionRequired": "Add a question after /btw",
|
||||
"session.btw.error": "Couldn’t answer that question",
|
||||
"session.btw.retry": "Retry",
|
||||
"session.btw.copy": "Copy answer",
|
||||
"common.moreOptions": "More options",
|
||||
"common.learnMore": "Learn more",
|
||||
"common.rename": "Rename",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SESSION_BTW_TAB } from "@/session/helpers"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
const instructions = [
|
||||
"The user is asking a quick side question about the conversation so far.",
|
||||
"Answer directly and concisely in markdown from what you already know.",
|
||||
"Do not call any tools and do not take any actions.",
|
||||
].join(" ")
|
||||
|
||||
const empty = {
|
||||
question: "",
|
||||
answer: "",
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
|
||||
export function createSessionBtw(session: SessionModel) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const server = useServerSDK()
|
||||
const [states, setStates] = createStore<Record<string, typeof empty>>({})
|
||||
const requests = new Map<string, number>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const state = () => states[session.identity.sessionKey()] ?? empty
|
||||
|
||||
createEffect(() => {
|
||||
const key = session.identity.sessionKey()
|
||||
onCleanup(() => {
|
||||
const controller = controllers.get(key)
|
||||
if (!controller) return
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
if (states[key]?.pending) setStates(key, { pending: false, error: true })
|
||||
})
|
||||
})
|
||||
|
||||
const open = () => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
if (tabs.active() !== SESSION_BTW_TAB) tabs.open(SESSION_BTW_TAB)
|
||||
}
|
||||
const ask = (value?: string) => {
|
||||
const question = value?.trim()
|
||||
if (!question) {
|
||||
showToast({ title: language.t("session.btw.questionRequired") })
|
||||
return
|
||||
}
|
||||
open()
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
|
||||
const key = session.identity.sessionKey()
|
||||
const request = (requests.get(key) ?? 0) + 1
|
||||
requests.set(key, request)
|
||||
controllers.get(key)?.abort()
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
const owner = session.ownership.capture()
|
||||
setStates(key, { question, answer: "", error: false, pending: true })
|
||||
return server.api.session
|
||||
.generate(
|
||||
{
|
||||
sessionID,
|
||||
prompt: [instructions, question].join("\n\n"),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
owner.run(() => {
|
||||
if (requests.get(key) !== request) return
|
||||
setStates(key, { answer: result.text.trim(), pending: false })
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
owner.run(() => {
|
||||
if (controller.signal.aborted || requests.get(key) !== request) return
|
||||
setStates(key, { error: true, pending: false })
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (controllers.get(key) === controller) controllers.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
command.register("session.btw", () => [
|
||||
{
|
||||
id: "session.btw",
|
||||
title: language.t("command.session.btw"),
|
||||
description: language.t("command.session.btw.description"),
|
||||
category: language.t("command.category.session"),
|
||||
slash: "btw",
|
||||
slashArguments: true,
|
||||
hidden: true,
|
||||
disabled: !session.isDesktop(),
|
||||
onSelect: (_source, input) => ask(input),
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
answer: () => state().answer,
|
||||
error: () => state().error,
|
||||
pending: () => state().pending,
|
||||
question: () => state().question,
|
||||
retry: () => ask(state().question),
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionBtwModel = ReturnType<typeof createSessionBtw>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { SessionBtwModel } from "./model"
|
||||
|
||||
export function SessionBtwPanel(props: { btw: SessionBtwModel }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
props.btw.answer()
|
||||
setCopied(false)
|
||||
})
|
||||
|
||||
const copy = () => {
|
||||
const answer = props.btw.answer()
|
||||
if (!answer) return
|
||||
void (platform.writeClipboardText?.(answer) ?? navigator.clipboard.writeText(answer)).then(
|
||||
() => setCopied(true),
|
||||
() => showToast({ title: language.t("common.requestFailed") }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
|
||||
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question()}</div>
|
||||
<Show when={props.btw.answer()}>
|
||||
<Tooltip value={copied() ? language.t("common.copied") : language.t("session.btw.copy")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name={copied() ? "check" : "outline-copy"} />}
|
||||
aria-label={copied() ? language.t("common.copied") : language.t("session.btw.copy")}
|
||||
onClick={copy}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<Switch>
|
||||
<Match when={props.btw.pending()}>
|
||||
<div
|
||||
data-component="session-working"
|
||||
role="status"
|
||||
class="flex h-9 items-center px-5 pt-3 text-[13px] font-[530] leading-text-compact"
|
||||
>
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.btw.error()}>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
|
||||
<div class="text-13-regular text-text-weak">{language.t("session.btw.error")}</div>
|
||||
<Button size="small" variant="outline" onClick={props.btw.retry}>
|
||||
{language.t("session.btw.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.btw.answer()}>
|
||||
<ScrollView class="absolute inset-0">
|
||||
<div class="px-5 py-4 pb-8">
|
||||
<Markdown text={props.btw.answer()} class="text-14-regular" />
|
||||
</div>
|
||||
</ScrollView>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
SESSION_BTW_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
@@ -74,6 +75,7 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
btwPanel: () => JSX.Element
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -227,7 +229,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
return active === SESSION_OPEN_FILE_TAB || active === activeFileTab()
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open"))
|
||||
@@ -385,6 +387,14 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={tab === SESSION_BTW_TAB}>
|
||||
<SortableTab tab={tab} index={tabs().all().indexOf(tab)} onTabClose={tabs().close}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="bubble-5" size="small" />
|
||||
<span>{language.t("session.tab.btw")}</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
</Match>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
@@ -583,6 +593,12 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === SESSION_BTW_TAB}>
|
||||
<Tabs.Content value={SESSION_BTW_TAB} class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
{props.btwPanel()}
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BTW_TAB,
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
@@ -236,6 +237,24 @@ describe("createSessionTabs", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes the BTW tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BTW_TAB, all: () => [SESSION_BTW_TAB] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([SESSION_BTW_TAB])
|
||||
expect(result.openedTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BTW_TAB)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BTW_TAB)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
|
||||
@@ -2,10 +2,11 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isSessionBrowserTab, SESSION_BTW_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_BTW_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
@@ -63,13 +64,17 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
() =>
|
||||
panelTabs().filter(
|
||||
(tab) => tab !== SESSION_OPEN_FILE_TAB && tab !== SESSION_BTW_TAB && !isSessionBrowserTab(tab),
|
||||
),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const activeTab = createMemo(() => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_BTW_TAB) return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (active === "review" && review()) return active
|
||||
@@ -89,6 +94,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_BTW_TAB) return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (!openedTabs().includes(active)) return
|
||||
|
||||
@@ -14,6 +14,8 @@ import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import type { SessionBtwModel } from "../btw/model"
|
||||
import { SessionBtwPanel } from "../btw/panel"
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
@@ -127,6 +129,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
btw: SessionBtwModel
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
@@ -153,6 +156,7 @@ export function SessionDesktopReview(props: {
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
btwPanel={() => <SessionBtwPanel btw={props.btw} />}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
|
||||
import { createSessionBtw } from "./btw/model"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -71,6 +72,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const btw = createSessionBtw(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
@@ -451,7 +453,12 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview
|
||||
review={review}
|
||||
browser={browser}
|
||||
btw={btw}
|
||||
present={store.sideReviewPresent}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -88,11 +88,12 @@ export interface CommandOption {
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
slashArguments?: boolean
|
||||
suggested?: boolean
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
when?: (event: KeyboardEvent) => boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise<void>
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
@@ -389,9 +390,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
return map
|
||||
})
|
||||
|
||||
const run = (id: string, source?: CommandSource) => {
|
||||
const run = (id: string, source?: CommandSource, input?: string) => {
|
||||
const option = optionMap().get(id)
|
||||
option?.onSelect?.(source)
|
||||
return option?.onSelect?.(source, input)
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
@@ -420,7 +421,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
option.onSelect?.("keybind")
|
||||
void option.onSelect?.("keybind")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -454,8 +455,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
|
||||
return {
|
||||
register,
|
||||
trigger(id: string, source?: CommandSource) {
|
||||
run(id, source)
|
||||
trigger(id: string, source?: CommandSource, input?: string) {
|
||||
return run(id, source, input)
|
||||
},
|
||||
keybind(id: string) {
|
||||
const config = keybindConfig(id)
|
||||
|
||||
@@ -166,7 +166,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
void item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("layout persistence", () => {
|
||||
test("keeps scoped state and salvages valid tab entries", () => {
|
||||
const key = "local\u0000L3Byb2plY3Q/session"
|
||||
const value = decode({
|
||||
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } },
|
||||
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b", "btw"], active: "btw" } },
|
||||
sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } },
|
||||
})
|
||||
expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } })
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode/ui/project-avatar"
|
||||
import { SessionStateKey } from "@/runtime/server/scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./session-tabs"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, SESSION_BTW_TAB, type SessionTabs } from "./session-tabs"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
@@ -97,8 +97,9 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un
|
||||
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
const path = sessionPath(key)
|
||||
return {
|
||||
all: normalizeSessionTabList(path, tabs.all),
|
||||
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
all: normalizeSessionTabList(path, tabs.all).filter((tab) => tab !== SESSION_BTW_TAB),
|
||||
active:
|
||||
tabs.active === SESSION_BTW_TAB ? undefined : tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const SESSION_BTW_TAB = "btw"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
@@ -276,7 +276,7 @@ export function TabNavItem(props: {
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
contenteditable={editing() ? "plaintext-only" : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -298,7 +298,7 @@ export function TitlebarTabStrip(props: {
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
isTabCloseTarget(event.target) ||
|
||||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||
(event.target instanceof Element && !!event.target.closest("[contenteditable]")),
|
||||
}),
|
||||
]}
|
||||
modifiers={[
|
||||
|
||||
@@ -422,6 +422,15 @@ describe("MCP OAuth", () => {
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
|
||||
})
|
||||
|
||||
test("requests offline_access without forcing a consent prompt", async () => {
|
||||
const { server } = authorizationServer({ scopes_supported: ["read", "offline_access"] })
|
||||
const { url } = await Effect.runPromise(
|
||||
Effect.scoped(start(server, { client_id: "client", scope: "read" })),
|
||||
).finally(() => server.stop(true))
|
||||
expect(url.searchParams.get("scope")).toBe("read offline_access")
|
||||
expect(url.searchParams.has("prompt")).toBe(false)
|
||||
})
|
||||
|
||||
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
|
||||
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
|
||||
const result = await Effect.runPromise(
|
||||
|
||||
@@ -727,7 +727,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: "New session",
|
||||
suggested: route.data.type === "session",
|
||||
category: "Session",
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
slash: { name: "new" },
|
||||
run: () => {
|
||||
const model = local.model.current()
|
||||
const agent = local.agent.current()
|
||||
@@ -749,6 +749,33 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session.clear",
|
||||
title: "Clear session",
|
||||
category: "Session",
|
||||
slash: { name: "clear" },
|
||||
run: () => {
|
||||
const model = local.model.current()
|
||||
const agent = local.agent.current()
|
||||
const current =
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined
|
||||
sessionTabs.close()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location: newSessionLocation(
|
||||
config.data.session.new_location,
|
||||
data.location.default().directory,
|
||||
current,
|
||||
location.error?.location,
|
||||
),
|
||||
})
|
||||
if (agent) local.agent.set(agent.id)
|
||||
if (model) local.model.set(model)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open.menu",
|
||||
title: "Open session or project",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
import { directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
const location = { directory, project: { id: "project", directory, canonical: directory } }
|
||||
const session = {
|
||||
id: "ses_clear",
|
||||
title: "Session to clear",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
|
||||
function render(state: string) {
|
||||
return createAppFixture({
|
||||
state,
|
||||
args: { sessionID: session.id },
|
||||
config: { animations: false, tabs: { mode: "on" } },
|
||||
fetch: (url) => {
|
||||
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
|
||||
return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("/clear replaces the active session tab", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await render(state.path)
|
||||
|
||||
await setup.waitForFrame((frame) => frame.includes("Session to clear"))
|
||||
await setup.mockInput.typeText("/clear")
|
||||
setup.mockInput.pressEnter()
|
||||
const frame = await setup.waitForFrame((frame) => !frame.includes("Session to clear"))
|
||||
|
||||
expect(frame).not.toContain("Session to clear")
|
||||
})
|
||||
|
||||
test("/new keeps the active session tab", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await render(state.path)
|
||||
|
||||
await setup.waitForFrame((frame) => frame.includes("Session to clear"))
|
||||
await setup.mockInput.typeText("/new")
|
||||
setup.mockInput.pressEnter()
|
||||
const frame = await setup.waitForFrame(
|
||||
(frame) => frame.includes("New session") && frame.includes("Session to clear"),
|
||||
)
|
||||
|
||||
expect(frame).toContain("Session to clear")
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..0054b779ff7b550ecf6d4008e177a888003f9c92 100644
|
||||
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f7a8ea0ae 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -977,7 +977,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
|
||||
@@ -10,8 +10,16 @@ index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..0054b779ff7b550ecf6d4008e177a888
|
||||
}
|
||||
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
|
||||
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
|
||||
@@ -1158,7 +1157,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
|
||||
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
|
||||
if (state) authorizationUrl.searchParams.set("state", state);
|
||||
if (scope) authorizationUrl.searchParams.set("scope", scope);
|
||||
- if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent");
|
||||
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
|
||||
return {
|
||||
authorizationUrl,
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..8e27c9320e14ba8816fda752f250f6ddb1cc7ed0 100644
|
||||
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..73a93a066c3540e131fa0770a74b4a7e8d9343a0 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -974,7 +974,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
|
||||
@@ -22,3 +30,11 @@ index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..8e27c9320e14ba8816fda752f250f6dd
|
||||
}
|
||||
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
|
||||
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
|
||||
@@ -1155,7 +1154,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
|
||||
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
|
||||
if (state) authorizationUrl.searchParams.set("state", state);
|
||||
if (scope) authorizationUrl.searchParams.set("scope", scope);
|
||||
- if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent");
|
||||
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
|
||||
return {
|
||||
authorizationUrl,
|
||||
|
||||
Reference in New Issue
Block a user