mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
feat(plugin): add a slot below the new session composer
This commit is contained in:
@@ -8,7 +8,10 @@ import {
|
||||
type App,
|
||||
type Keybinds,
|
||||
type MountedSession,
|
||||
type Project,
|
||||
type Router,
|
||||
type ServerRef,
|
||||
type SlotMap,
|
||||
type Servers,
|
||||
type SessionScreen,
|
||||
type Ipc,
|
||||
@@ -53,6 +56,7 @@ import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
import { ExtensionPlugins } from "../src/runtime/plugin/extension-plugins"
|
||||
import { SessionPluginPills } from "../src/session/composer/plugin-pills"
|
||||
import { ui } from "../src/runtime/plugin/ui"
|
||||
import { NewSessionComposerSlot } from "../src/new-session/composer-after"
|
||||
|
||||
export {
|
||||
bindPlugin,
|
||||
@@ -826,6 +830,110 @@ export function mountComposerPills(input: { definitions: readonly Entry[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host and the `new-session.composer.after` slot under a composer, in a 480px new session panel laid
|
||||
* out as the screen lays it out. The draft starts in `/repo` of the `repo` project on the `local` server; `target`
|
||||
* moves it as the location picker does, and its composer records each call. `define` replaces the plugins, as plugin
|
||||
* GUIs join and leave.
|
||||
*/
|
||||
export function mountNewSessionComposer(input: { definitions: readonly Entry[] }) {
|
||||
const container = document.createElement("div")
|
||||
container.dataset.testid = "new-session"
|
||||
document.body.appendChild(container)
|
||||
const hosts: PluginHost[] = []
|
||||
const captured: Captured = {}
|
||||
const calls: string[] = []
|
||||
const [definitions, setDefinitions] = createSignal(input.definitions)
|
||||
|
||||
const [target, setTarget] = createStore<{ directory: string | undefined; project: string | undefined }>({
|
||||
directory: "/repo",
|
||||
project: "repo",
|
||||
})
|
||||
|
||||
// SAFETY: the slot passes the server through; the test's plugins read only `id`.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const server = { id: "local" } as unknown as ServerRef
|
||||
|
||||
const slot: SlotMap["new-session.composer.after"] = {
|
||||
server,
|
||||
get project() {
|
||||
// SAFETY: the test's plugins read only the project's `name`.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
return target.project ? ({ name: target.project, worktree: "/repo" } as unknown as Project) : undefined
|
||||
},
|
||||
get directory() {
|
||||
return target.directory
|
||||
},
|
||||
composer: {
|
||||
text: () => "",
|
||||
insert: (text) => void calls.push(`insert ${text}`),
|
||||
setText: (text) => void calls.push(`setText ${text}`),
|
||||
focus: () => void calls.push("focus"),
|
||||
},
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
const host = usePluginHost()
|
||||
hosts.push(host)
|
||||
|
||||
return (
|
||||
<Show when={host.ready()}>
|
||||
<div data-component="new-session" style={{ position: "relative", height: "480px", overflow: "hidden" }}>
|
||||
<div style={{ position: "absolute", top: "120px", left: "0", right: "0" }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div data-testid="composer" style={{ height: "120px" }} />
|
||||
<NewSessionComposerSlot input={slot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Host() {
|
||||
const apis = createHostApis()
|
||||
captured.apis = apis
|
||||
|
||||
return (
|
||||
<PluginHostProvider
|
||||
definitions={definitions}
|
||||
disabled={() => new Set<string>()}
|
||||
apis={apis.apis}
|
||||
whenMounted={apis.whenMounted}
|
||||
>
|
||||
<Panel />
|
||||
</PluginHostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<PlatformProvider value={web}>
|
||||
<DialogProvider>
|
||||
<Host />
|
||||
</DialogProvider>
|
||||
</PlatformProvider>
|
||||
</LanguageProvider>
|
||||
),
|
||||
container,
|
||||
)
|
||||
|
||||
return {
|
||||
/** Moves the draft, as picking a project or worktree does. */
|
||||
target: (next: { directory: string | undefined; project: string | undefined }) => setTarget(next),
|
||||
define: (next: readonly Entry[]) => setDefinitions(next),
|
||||
/** What plugins called on the draft's composer, in order. */
|
||||
calls,
|
||||
status: (id: string) => hosts[0]?.status(id),
|
||||
failure: (id: string) => hosts[0]?.failure(id),
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host and one message's production Markdown inside the session screen's `PluginLinks`, for a session
|
||||
* whose directory is `/repo`. `show(text, streaming)` replaces the message as a stream does; `define` replaces the
|
||||
|
||||
@@ -9,11 +9,12 @@ import type {
|
||||
Live,
|
||||
SessionRef,
|
||||
SessionScreen,
|
||||
SlotMap,
|
||||
} from "@opencode/plugin/gui"
|
||||
import type { BuiltinPanelTab } from "@opencode/plugin/gui/builtin"
|
||||
import type { Bridge, BridgeMessage, EnableState, Installed } from "@opencode/plugin/gui/host"
|
||||
import type { JSHandle } from "@playwright/test"
|
||||
import type { mountCodeBlocks, mountThemes } from "./plugin-host.fixture"
|
||||
import type { mountCodeBlocks, mountNewSessionComposer, mountThemes } from "./plugin-host.fixture"
|
||||
import { expect, sourceURL, story } from "../../stories/playwright/story"
|
||||
|
||||
const fixture = sourceURL(new URL("./plugin-host.fixture.tsx", import.meta.url))
|
||||
@@ -21,6 +22,12 @@ const fixture = sourceURL(new URL("./plugin-host.fixture.tsx", import.meta.url))
|
||||
/** What the code block cases drive from the page. */
|
||||
type CodeBlocksHost = ReturnType<typeof mountCodeBlocks>
|
||||
|
||||
/** What the new session composer cases drive from the page. */
|
||||
type NewSessionHost = ReturnType<typeof mountNewSessionComposer>
|
||||
|
||||
/** What a `new-session.composer.after` render receives. */
|
||||
type NewSessionInput = SlotMap["new-session.composer.after"]
|
||||
|
||||
/** What the theme preview case drives from the page. */
|
||||
type ThemesHost = ReturnType<typeof mountThemes>
|
||||
|
||||
@@ -2231,3 +2238,133 @@ story("code block content follows plugins that join and leave after the message
|
||||
await expect(root.getByTestId("chart")).toHaveCount(0)
|
||||
await expect(root.locator("pre code")).toHaveText("A,B")
|
||||
})
|
||||
|
||||
story(
|
||||
"plugins fill the space below the new session composer in order, follow the draft's target, and fail alone",
|
||||
async ({ page }) => {
|
||||
const host = await page.evaluateHandle(async (fixture) => {
|
||||
const { mountNewSessionComposer, until, Slot } = await import(fixture)
|
||||
|
||||
const host: NewSessionHost = mountNewSessionComposer({
|
||||
definitions: [
|
||||
{
|
||||
id: "recent",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) =>
|
||||
void ctx.add(Slot, {
|
||||
at: "new-session.composer.after",
|
||||
order: 1,
|
||||
render: (input: NewSessionInput) => {
|
||||
document.body.dataset.renders = `${Number(document.body.dataset.renders ?? 0) + 1}`
|
||||
|
||||
return () => `${input.server.id} ${input.project?.name ?? "none"} ${input.directory ?? "new"}`
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) =>
|
||||
void ctx.add(Slot, {
|
||||
at: "new-session.composer.after",
|
||||
render: (input: NewSessionInput) =>
|
||||
Object.assign(document.createElement("button"), {
|
||||
textContent: "Plan",
|
||||
onclick: () => {
|
||||
input.composer.insert("Plan the release")
|
||||
input.composer.focus()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "broken",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) =>
|
||||
void ctx.add(Slot, {
|
||||
at: "new-session.composer.after",
|
||||
render: () => {
|
||||
throw new Error("slot failed")
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await until(() => !!host.failure("broken") && host.status("recent") === "active")
|
||||
|
||||
return host
|
||||
}, fixture)
|
||||
|
||||
const area = page.getByTestId("new-session").locator('[data-slot="new-session-composer-after"]')
|
||||
await expect(area).toHaveText("Planlocal repo /repo")
|
||||
expect(await host.evaluate((host) => [host.status("starter"), host.failure("broken")])).toEqual([
|
||||
"active",
|
||||
{ phase: "render", error: expect.stringContaining("slot failed") },
|
||||
])
|
||||
|
||||
// A new worktree has no directory yet; the render stays mounted and reads the next target.
|
||||
await host.evaluate((host) => host.target({ directory: undefined, project: "repo" }))
|
||||
await expect(area).toHaveText("Planlocal repo new")
|
||||
await host.evaluate((host) => host.target({ directory: "/other", project: undefined }))
|
||||
await expect(area).toHaveText("Planlocal none /other")
|
||||
await expect(page.locator("body")).toHaveAttribute("data-renders", "1")
|
||||
|
||||
await area.getByRole("button", { name: "Plan" }).click()
|
||||
expect(await host.evaluate((host) => host.calls)).toEqual(["insert Plan the release", "focus"])
|
||||
},
|
||||
)
|
||||
|
||||
story(
|
||||
"the space below the new session composer takes no room while empty and scrolls above the panel's bottom",
|
||||
async ({ page }) => {
|
||||
const host = await page.evaluateHandle(async (fixture) => {
|
||||
const { mountNewSessionComposer, until } = await import(fixture)
|
||||
const host: NewSessionHost = mountNewSessionComposer({ definitions: [] })
|
||||
await until(
|
||||
() => !!document.querySelector('[data-testid="new-session"] [data-slot="new-session-composer-after"]'),
|
||||
)
|
||||
|
||||
return host
|
||||
}, fixture)
|
||||
|
||||
const root = page.getByTestId("new-session")
|
||||
const area = root.locator('[data-slot="new-session-composer-after"]')
|
||||
const composer = root.getByTestId("composer")
|
||||
await expect(area).toBeHidden()
|
||||
const before = await composer.boundingBox()
|
||||
|
||||
await host.evaluate(async (host, fixture) => {
|
||||
const { Slot } = await import(fixture)
|
||||
|
||||
host.define([
|
||||
{
|
||||
id: "tall",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) =>
|
||||
void ctx.add(Slot, {
|
||||
at: "new-session.composer.after",
|
||||
render: () =>
|
||||
Object.assign(document.createElement("div"), { textContent: "tall", style: "height: 600px" }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
])
|
||||
}, fixture)
|
||||
|
||||
await expect(area).toHaveText("tall")
|
||||
expect(await composer.boundingBox()).toEqual(before)
|
||||
const panel = await root.locator('[data-component="new-session"]').boundingBox()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await area.boundingBox()
|
||||
|
||||
return box && panel ? panel.y + panel.height - (box.y + box.height) : 0
|
||||
})
|
||||
.toBeGreaterThanOrEqual(56)
|
||||
expect(await area.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Show, createSignal, onMount } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import type { NewSessionComposer, SlotMap } from "@opencode/plugin/gui"
|
||||
import { createComposerEditorActions } from "@/composer/editor/actions"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { usePluginAttachment } from "@/runtime/plugin/host-apis"
|
||||
import { PluginSlot } from "@/runtime/plugin/render"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
|
||||
// Room the slot leaves at the bottom of the panel, for the tip pinned there.
|
||||
const BOTTOM_INSET = 56
|
||||
|
||||
/** Plugin content below the new session composer, for the draft the screen shows. */
|
||||
export function NewSessionComposerAfter(props: { composer: ComposerModel; workspace: NewSessionWorkspaceController }) {
|
||||
const server = useServer()
|
||||
const attachment = usePluginAttachment()
|
||||
// The draft's own prompt: the screen suspends until it loads, so writes never replace a stored draft.
|
||||
const draft = useComposerState().capture()
|
||||
const edit = () => createComposerEditorActions(draft.store)
|
||||
|
||||
const composer: NewSessionComposer = {
|
||||
text: () =>
|
||||
draft
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join(""),
|
||||
insert(text) {
|
||||
const value = text.replace(/\r\n?/g, "\n")
|
||||
|
||||
if (value) edit().addText(value)
|
||||
},
|
||||
setText: (text) => edit().setText(text.replace(/\r\n?/g, "\n")),
|
||||
focus: () => props.composer.restoreFocus(),
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={attachment.server(server.key)}>
|
||||
{(ref) => (
|
||||
<NewSessionComposerSlot
|
||||
input={{
|
||||
get server() {
|
||||
return ref()
|
||||
},
|
||||
get project() {
|
||||
return server.ctx.projects.forDirectory(props.workspace.project.root())
|
||||
},
|
||||
get directory() {
|
||||
const worktree = props.workspace.selection.value()
|
||||
|
||||
if (worktree === "create") return
|
||||
|
||||
return worktree === "main" ? props.workspace.project.root() : worktree
|
||||
},
|
||||
composer,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `new-session.composer.after` slot in the new session panel's content column. It takes no room while nothing
|
||||
* renders, and scrolls once it reaches the bottom of the panel, so the composer above it never moves.
|
||||
*/
|
||||
export function NewSessionComposerSlot(props: { input: SlotMap["new-session.composer.after"] }) {
|
||||
const [height, setHeight] = createSignal<number>()
|
||||
let area!: HTMLDivElement
|
||||
|
||||
onMount(() => {
|
||||
const panel = area.closest<HTMLElement>('[data-component="new-session"]')
|
||||
|
||||
if (!panel || !area.parentElement) return
|
||||
|
||||
// The composer growing moves the area down, and the panel resizing moves its bottom.
|
||||
createResizeObserver([panel, area.parentElement], () =>
|
||||
setHeight(
|
||||
Math.max(0, Math.floor(panel.getBoundingClientRect().bottom - area.getBoundingClientRect().top - BOTTOM_INSET)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={area}
|
||||
data-slot="new-session-composer-after"
|
||||
data-scrollable
|
||||
class="flex min-h-0 flex-col gap-3 overflow-y-auto empty:hidden *:shrink-0"
|
||||
style={{ "max-height": height() === undefined ? undefined : `${height()}px` }}
|
||||
>
|
||||
<PluginSlot at="new-session.composer.after" input={props.input} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Schema } from "effect"
|
||||
import { createPresence } from "@/runtime/presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import { NewSessionComposerAfter } from "@/new-session/composer-after"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
@@ -133,6 +134,7 @@ export function NewSessionView(props: {
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
<NewSessionComposerAfter composer={props.composer} workspace={props.workspace} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1207,6 +1207,8 @@ export function createPluginAttachment(apis: HostApis) {
|
||||
attach: () => apis.attach(mounted),
|
||||
/** The routed `MountedSession`. */
|
||||
current,
|
||||
/** The `ServerRef` `Servers.get` returns for a listed server's id. */
|
||||
server,
|
||||
closed,
|
||||
/** Opens and selects a side tab by key, as a reopened closed tab comes back. */
|
||||
openTab: (key: string, session: SessionRef) => open(key, session, { tab: "select" }),
|
||||
|
||||
@@ -252,28 +252,33 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the
|
||||
|
||||
### Registries
|
||||
|
||||
| Registry | Process | What an item is |
|
||||
| ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [`Command`](../plugin/src/gui/registries.ts) | window | A palette command with an optional keybind and slash command |
|
||||
| [`MenuItem`](../plugin/src/gui/registries.ts) | window | An item of a host menu: the side panel + menu, Add server, a server row |
|
||||
| [`Panel`](../plugin/src/gui/registries.ts) | window | Tabs in the session's side region |
|
||||
| [`SettingsPage`](../plugin/src/gui/registries.ts) | window | A settings page, a section on a host page, or rows in a host section |
|
||||
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
|
||||
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
|
||||
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
|
||||
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
|
||||
| [`CodeBlock`](../plugin/src/gui/registries.ts) | window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
|
||||
| [`CodeBlockAction`](../plugin/src/gui/registries.ts) | window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
|
||||
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
|
||||
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
|
||||
| [`Theme`](../plugin/src/gui/registries.ts) | window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
|
||||
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |
|
||||
| Registry | Process | What an item is |
|
||||
| ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [`Command`](../plugin/src/gui/registries.ts) | window | A palette command with an optional keybind and slash command |
|
||||
| [`MenuItem`](../plugin/src/gui/registries.ts) | window | An item of a host menu: the side panel + menu, Add server, a server row |
|
||||
| [`Panel`](../plugin/src/gui/registries.ts) | window | Tabs in the session's side region |
|
||||
| [`SettingsPage`](../plugin/src/gui/registries.ts) | window | A settings page, a section on a host page, or rows in a host section |
|
||||
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
|
||||
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
|
||||
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
|
||||
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
|
||||
| [`CodeBlock`](../plugin/src/gui/registries.ts) | window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
|
||||
| [`CodeBlockAction`](../plugin/src/gui/registries.ts) | window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
|
||||
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`, `new-session.composer.after`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
|
||||
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
|
||||
| [`Theme`](../plugin/src/gui/registries.ts) | window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
|
||||
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |
|
||||
|
||||
`session.location.menu` contributes items after the project menu's location details. It receives the owning `screen`,
|
||||
the live `session`, and `shown`; keep data subscriptions active only while `shown` is true. Loading headers without an
|
||||
owning screen show the host's location details without this slot. Render its rows with `ctx.ui.MenuItem`,
|
||||
`ctx.ui.MenuSub`, `ctx.ui.MenuGroup` and `ctx.ui.MenuSeparator`, so they navigate and close like the host's rows.
|
||||
|
||||
`new-session.composer.after` stacks content below the new session composer, before a session exists. It receives the
|
||||
draft's `server`, `project` and `directory` (undefined while a new worktree is selected) as reactive getters, and the
|
||||
draft's `composer` (`text`, `insert`, `setText`, `focus`). The composer never moves: the area takes no room while
|
||||
empty and scrolls once it reaches the bottom of the panel.
|
||||
|
||||
`session.location.branch.menu` (built-ins only, through `BuiltinSlot`) contributes items after Copy branch name in
|
||||
the branch submenu, with the same `screen`, live `session`, and project-menu `shown` context. It renders only when a
|
||||
branch name and owning screen are available. Use it for actions on the branch's working directory, such as opening
|
||||
|
||||
@@ -685,6 +685,41 @@ export interface Composer {
|
||||
focused(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The new session screen's composer: the draft's prompt, before a session exists. Nothing here sends the prompt; the
|
||||
* user does. The draft has loaded by the time a slot receives it.
|
||||
*/
|
||||
export interface NewSessionComposer {
|
||||
/**
|
||||
* The prompt's text, as plain text: a mention reads as its `@` text, and attachments are left out. In shell mode, the
|
||||
* shell command. Reactive.
|
||||
*
|
||||
* @example
|
||||
* const empty = () => input.composer.text().trim() === ""
|
||||
*/
|
||||
text(): string
|
||||
/**
|
||||
* Inserts plain text into the prompt as typing would, at the cursor the user last left in it, else at the end, and
|
||||
* moves the cursor after it. Selected text is not replaced. The text is not parsed: `@path` stays text rather than a
|
||||
* mention, and line breaks stay line breaks. Focus stays where it is; call `focus` to move it. Does nothing for empty
|
||||
* text.
|
||||
*
|
||||
* @param text - The text to insert.
|
||||
*
|
||||
* @example
|
||||
* input.composer.insert("Summarize the open pull requests")
|
||||
*/
|
||||
insert(text: string): void
|
||||
/**
|
||||
* Replaces the prompt's text, mentions included, and moves the cursor to the end. Attachments stay.
|
||||
*
|
||||
* @param text - The new text. Empty clears the text.
|
||||
*/
|
||||
setText(text: string): void
|
||||
/** Moves keyboard focus to the prompt, at its cursor. */
|
||||
focus(): void
|
||||
}
|
||||
|
||||
/** Work the session moved to the background. */
|
||||
export interface BackgroundTask {
|
||||
/** The task's id. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import type { IconName } from "./icon-name.js"
|
||||
import { Registry } from "./core.js"
|
||||
import type { SessionRef, MountedSession, SessionScreen } from "./host-apis.js"
|
||||
import type { SessionRef, MountedSession, NewSessionComposer, Project, ServerRef, SessionScreen } from "./host-apis.js"
|
||||
|
||||
/** The name of a shared icon; derived from the dependency-free UI artwork catalog, not a component's props. */
|
||||
export type { IconName }
|
||||
@@ -897,6 +897,26 @@ export interface SlotMap {
|
||||
/** The routed session; a new object when another session is routed. */
|
||||
readonly session: MountedSession
|
||||
}
|
||||
/**
|
||||
* Below the composer of the new session screen, where the user writes the first prompt of a session that does not
|
||||
* exist yet: for example recent sessions of the project or starter prompts. Contributions stack in `order`, the
|
||||
* composer's width. The composer never moves: the area takes no room while nothing renders, grows down from under
|
||||
* the composer, and scrolls once it reaches the bottom of the screen, which on a short window or behind a touch
|
||||
* keyboard can leave it no room at all. The fields are reactive getters: read them where you use them.
|
||||
*/
|
||||
readonly "new-session.composer.after": {
|
||||
/** The server the session starts on: the same object `ctx.servers.get` returns for its id. */
|
||||
readonly server: ServerRef
|
||||
/** The selected project, with its worktrees; undefined while the draft has none or the server is still listing it. */
|
||||
readonly project: Project | undefined
|
||||
/**
|
||||
* The directory the session runs in: the project's root, or the selected worktree. Undefined while a new worktree
|
||||
* is selected, which the server creates when the session starts.
|
||||
*/
|
||||
readonly directory: string | undefined
|
||||
/** The draft's composer, to fill the prompt in for the user to send. */
|
||||
readonly composer: NewSessionComposer
|
||||
}
|
||||
}
|
||||
|
||||
/** Content for one of the host's slots, typed by `at`. */
|
||||
|
||||
@@ -996,7 +996,7 @@ interface SettingEntry {
|
||||
|
||||
### Slots
|
||||
|
||||
`Slot` renders UI into one of four window slots.
|
||||
`Slot` renders UI into one of the window's slots.
|
||||
|
||||
```tsx
|
||||
import { Slot } from "@opencode/plugin/gui"
|
||||
@@ -1013,6 +1013,8 @@ ctx.add(Slot, {
|
||||
`false`.
|
||||
- `session.location.menu` adds items to the project menu, after its location details.
|
||||
- `session.panel.end` holds actions at the trailing end of the side region's tab strip.
|
||||
- `new-session.composer.after` sits below the new session composer, before the session exists. See
|
||||
[Below the new session composer](#below-the-new-session-composer).
|
||||
- `order` sorts the items in one slot.
|
||||
|
||||
`session.location.menu` receives `shown`, which is `true` while the project menu is open. Subscribe to location data
|
||||
@@ -1035,6 +1037,44 @@ ctx.add(Slot, {
|
||||
})
|
||||
```
|
||||
|
||||
#### Below the new session composer
|
||||
|
||||
`new-session.composer.after` shows content under the composer of a new session, such as starter prompts or the
|
||||
project's recent sessions. It receives the draft's target and its composer, all as reactive getters:
|
||||
|
||||
```tsx
|
||||
ctx.add(Slot, {
|
||||
at: "new-session.composer.after",
|
||||
render: (input) => (
|
||||
<ctx.ui.Button
|
||||
icon="review"
|
||||
disabled={!input.directory}
|
||||
onClick={() => {
|
||||
input.composer.insert(ctx.t("prompt.review"))
|
||||
input.composer.focus()
|
||||
}}
|
||||
>
|
||||
{ctx.t("review")}
|
||||
</ctx.ui.Button>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
- `server` is the server the session starts on, the same `ServerRef` that `ctx.servers.get` returns.
|
||||
- `project` is the selected project. It is `undefined` while the draft has none.
|
||||
- `directory` is where the session runs: the project root or the selected worktree. It is `undefined` while a new
|
||||
worktree is selected, because the server creates that worktree when the session starts.
|
||||
- `composer` reads and fills the draft's prompt: `text()`, `insert(text)`, `setText(text)` and `focus()`. The user
|
||||
sends it.
|
||||
- The composer never moves. The area takes no room while nothing renders, and scrolls once it reaches the bottom of the
|
||||
screen. Keep content compact: on a short window or behind a touch keyboard it may have no room.
|
||||
|
||||
To open one of the project's sessions from the slot, pass its id to `ctx.tabs.open`:
|
||||
|
||||
```ts
|
||||
ctx.tabs.open({ server: input.server.id, id: session.id }, { focus: true })
|
||||
```
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
@@ -1047,6 +1087,19 @@ interface SlotMap {
|
||||
"session.header": { screen: SessionScreen; session: MountedSession; active: boolean }
|
||||
"session.location.menu": { screen: SessionScreen; session: MountedSession; shown: boolean }
|
||||
"session.panel.end": { screen: SessionScreen; session: MountedSession }
|
||||
"new-session.composer.after": {
|
||||
server: ServerRef
|
||||
project: Project | undefined
|
||||
directory: string | undefined
|
||||
composer: NewSessionComposer
|
||||
}
|
||||
}
|
||||
|
||||
interface NewSessionComposer {
|
||||
text(): string
|
||||
insert(text: string): void
|
||||
setText(text: string): void
|
||||
focus(): void
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user