Compare commits

...
Author SHA1 Message Date
Shoubhit Dash ab8a5f225c feat(plugin): read and append to the TUI prompt
Add context.ui.prompt.current() and append(text) for TUI plugins.
current() returns the composer text, a UTF-16 selection, and the mode,
and is reactive. append() adds text at the end without touching
attachments, and returns false when no composer is mounted or the
plugin is no longer active.
2026-10-04 19:15:02 +05:30
10 changed files with 477 additions and 17 deletions

No files matched your search

+16
View File
@@ -487,6 +487,21 @@ export interface Keymap {
}
}
export interface PromptDraft {
/** Composer text as shown. Attachments and collapsed pastes appear as their labels, e.g. `@src/app.ts`, `[Pasted ~3 lines]`. */
readonly text: string
/** Selected range of `text` in UTF-16 indices (String.prototype.slice units). start === end is the caret. */
readonly selection: { readonly start: number; readonly end: number }
readonly mode: "normal" | "shell"
}
export interface Prompt {
/** The mounted composer's draft, or undefined when no composer is mounted. Reactive when read in a Solid computation. */
current(): PromptDraft | undefined
/** Appends text to the end of the composer and moves the cursor after it. Existing attachments are unchanged. Returns false when no composer is mounted or this plugin is no longer active. */
append(text: string): boolean
}
export interface UI {
readonly dialog: Dialog
readonly toast: Toast
@@ -537,6 +552,7 @@ export interface UI {
set(variant: string | undefined): boolean
}
}
readonly prompt: Prompt
/** Claims a place in the slot tree; see SlotClaim. */
readonly slot: (claim: SlotClaim) => () => void
}
@@ -31,6 +31,7 @@ export type AutocompleteRef = {
onInput: (value: string) => void
visible: false | "reference" | "command" | "directory"
completeQueueableCommand: () => boolean
close: () => void
}
export type AutocompleteOption = {
@@ -777,6 +778,9 @@ export function Autocomplete(props: {
select()
return true
},
close() {
hide()
},
onInput(value) {
if (dismissedValue() === value) return
setDismissedValue(undefined)
+43 -9
View File
@@ -1,5 +1,6 @@
import {
BoxRenderable,
CliRenderEvents,
RGBA,
TextareaRenderable,
MouseEvent,
@@ -59,6 +60,7 @@ import { useInteractivity } from "../../context/interactivity"
import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptDraft } from "@opencode/plugin/tui/context"
import {
deduplicatePromptImages,
preserveMentionlessPromptAttachments,
@@ -95,6 +97,8 @@ export type PromptRef = {
blur(): void
focus(): void
submit(): void
draft(): PromptDraft | undefined
append(text: string): boolean
}
const DRAFT_RETENTION_MIN_CHARS = 20
@@ -311,6 +315,10 @@ export function Prompt(props: PromptProps) {
],
}))
const [cursorVersion, setCursorVersion] = createSignal(0)
// Mouse selections change the textarea without a cursor event.
const bumpCursor = () => setCursorVersion((value) => value + 1)
renderer.on(CliRenderEvents.SELECTION, bumpCursor)
onCleanup(() => renderer.off(CliRenderEvents.SELECTION, bumpCursor))
const connected = useConnected()
function promptModelWarning() {
@@ -697,6 +705,31 @@ export function Prompt(props: PromptProps) {
submit() {
void submit()
},
draft() {
cursorVersion()
if (input.isDestroyed) return
const range = input.getSelection() ?? { start: input.cursorOffset, end: input.cursorOffset }
return {
text: input.plainText,
// Textarea offsets count display cells, and wide characters and tabs span several. The text before an offset
// gives its UTF-16 index.
selection: { start: input.getTextRange(0, range.start).length, end: input.getTextRange(0, range.end).length },
mode: store.mode,
}
},
append(text) {
if (input.isDestroyed) return false
if (!text) return true
auto()?.close()
// gotoBufferEnd only collapses an active selection, so clear it first to reach the end.
input.clearSelection()
input.gotoBufferEnd()
input.insertText(text)
// Content events arrive on a microtask and never after the textarea is destroyed. Sync now so a same-tick read
// or unmount sees the appended text.
syncFromInput()
return true
},
}
function resetComposer() {
@@ -873,6 +906,12 @@ export function Prompt(props: PromptProps) {
)
}
function syncFromInput() {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}
const stashCommands = createMemo(() =>
[
{
@@ -1110,10 +1149,7 @@ export function Prompt(props: PromptProps) {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
if (input && !input.isDestroyed && input.plainText !== store.prompt.text) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (input && !input.isDestroyed && input.plainText !== store.prompt.text) syncFromInput()
if (move.creating()) return false
if (auto()?.visible) return false
const trimmed = store.prompt.text.trim()
@@ -1751,11 +1787,8 @@ export function Prompt(props: PromptProps) {
maxHeight={maxHeight()}
cursorStyle={config.cursor}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
syncFromInput()
auto()?.onInput(input.plainText)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
@@ -1803,6 +1836,7 @@ export function Prompt(props: PromptProps) {
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
onCleanup(promptRef.bind(ref))
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
+6 -4
View File
@@ -1,17 +1,19 @@
import { createSignal } from "solid-js"
import { createSimpleContext } from "./helper"
import type { PromptRef } from "../component/prompt"
export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({
name: "PromptRef",
init: () => {
let current: PromptRef | undefined
const [current, setCurrent] = createSignal<PromptRef>()
return {
get current() {
return current
return current()
},
set(ref: PromptRef | undefined) {
current = ref
bind(ref: PromptRef) {
setCurrent(ref)
return () => setCurrent((value) => (value === ref ? undefined : value))
},
}
},
+10
View File
@@ -31,6 +31,7 @@ import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { useOptionalPanel } from "../context/panel"
import { useLocal } from "../context/local"
import { usePromptRef } from "../context/prompt"
import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
@@ -82,6 +83,7 @@ export function usePluginHost() {
sessionTabs: useSessionTabs(),
panel: useOptionalPanel(),
local: useLocal(),
prompt: usePromptRef(),
}
}
@@ -295,6 +297,14 @@ export function createPluginContext(input: {
},
},
},
prompt: {
current: () => host.prompt.current?.draft(),
append(text: unknown) {
if (typeof text !== "string") throw new TypeError("Prompt text must be a string")
if (!cleanups) return false
return host.prompt.current?.append(text) ?? false
},
},
slot(value: SlotClaim) {
// Keys are counter-suffixed so one plugin may claim several places;
// order within the plugin is registration order.
-3
View File
@@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "
import { Logo } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { useEditorContext } from "../context/editor"
import { useData } from "../context/data"
@@ -24,7 +23,6 @@ const placeholder = {
export function Home() {
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
const args = useArgs()
const local = useLocal()
@@ -52,7 +50,6 @@ export function Home() {
const bind = (r: PromptRef | undefined) => {
setRef(r)
promptRef.set(r)
if (once || !r || route.prompt || !args.prompt) return
r.set({ text: args.prompt, files: [], agents: [], pasted: [] })
once = true
@@ -375,7 +375,6 @@ export function Session(props: {
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
setPrompt(r)
promptRef.set(r)
if (seeded || !route.prompt || !r) return
seeded = true
r.set(route.prompt)
@@ -0,0 +1,64 @@
import { Plugin } from "@opencode/plugin/tui"
const generation = "generation 1"
export default Plugin.define({
id: "fixture.prompt-api",
setup(context) {
const prompt = context.ui.prompt
const report = (label: string, value: boolean) => context.ui.toast.show({ message: `${label} ${value}` })
const initial: { append?: (text: string) => boolean } = {}
const [firstGeneration, setFirstGeneration] = context.storage.memory("first", { initial })
if (!firstGeneration.append)
setFirstGeneration((draft) => {
draft.append = (text) => prompt.append(text)
})
context.ui.router.register({ name: "away", render: () => <text>Away route</text> })
context.ui.slot({ append: "app", render: () => <text>draft {JSON.stringify(prompt.current() ?? null)}</text> })
context.keymap.layer(() => ({
mode: "global",
commands: [
{ bind: "f3", run: () => report("append", prompt.append("[x]")) },
{ bind: "f4", run: () => report("empty", prompt.append("")) },
{ bind: "f12", run: () => report("lines", prompt.append("\r\nsecond\rthird")) },
{
bind: "f1",
run() {
try {
prompt.append(context.options.missing)
} catch (error) {
context.ui.toast.show({ message: `untyped threw ${error instanceof TypeError ? "TypeError" : error}` })
}
},
},
{
bind: "f5",
run() {
prompt.append(" kept")
context.ui.router.navigate({ type: "plugin", name: "away" })
context.ui.router.navigate({ type: "home" })
},
},
{
bind: "f6",
run() {
context.ui.router.navigate({ type: "plugin", name: "away" })
report("away", prompt.append("[x]"))
},
},
{ bind: "f7", run: () => context.ui.router.navigate({ type: "home" }) },
{ bind: "f8", run: () => context.keymap.dispatch("prompt.clear") },
{ bind: "f9", run: () => context.keymap.dispatch("prompt.submit") },
{
bind: "f10",
run() {
prompt.append(" now")
context.keymap.dispatch("prompt.submit")
},
},
{ bind: "f11", run: () => report("first", firstGeneration.append?.("[x]") ?? false) },
],
}))
context.ui.toast.show({ message: `Prompt fixture ready ${generation}` })
},
})
+306
View File
@@ -0,0 +1,306 @@
import { beforeEach, expect, test } from "bun:test"
import { mkdir, symlink } from "node:fs/promises"
import path from "node:path"
import { takeDraft } from "../src/component/prompt/draft-stash"
import { createAppFixture } from "./fixture/app"
import { tmpdir } from "./fixture/fixture"
import { directory, json, worktree } from "./fixture/tui-client"
type Fixture = Awaited<ReturnType<typeof createAppFixture>>
const location = { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }
beforeEach(() => {
takeDraft(undefined)
})
test("current() follows typing, the caret, a selection, and the mode in UTF-16 units", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
setup.mockInput.pressKey("!")
await setup.waitForFrame((frame) => frame.includes(draft("", 0, 0, "shell")))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => frame.includes(draft("", 0)))
await setup.mockInput.typeText("日本")
await setup.waitForFrame((frame) => frame.includes(draft("日本", 2)))
await setup.mockInput.typeText(" abc")
await setup.waitForFrame((frame) => frame.includes(draft("日本 abc", 6)))
setup.mockInput.pressArrow("left", { shift: true })
setup.mockInput.pressArrow("left", { shift: true })
setup.mockInput.pressArrow("left", { shift: true })
await setup.waitForFrame((frame) => frame.includes(draft("日本 abc", 3, 6)))
})
test("current() follows a mouse selection in the composer", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.mockInput.typeText("hello world")
await setup.waitForFrame((frame) => frame.includes(draft("hello world", 11)))
const rows = setup.captureCharFrame().split("\n")
const row = rows.findIndex((line) => line.includes("hello world") && !line.includes("draft"))
const column = rows[row]!.indexOf("hello world")
await setup.mockMouse.drag(column, row, column + 4, row)
await setup.waitForFrame((frame) => frame.includes(draft("hello world", 0, 5)))
})
test("append adds text at the end, moves the caret after it, and undoes in one step", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
setup.mockInput.pressKey("F3")
await setup.waitForFrame((frame) => frame.includes("append true") && frame.includes(draft("[x]", 3)))
await setup.mockInput.typeText("a")
await setup.waitForFrame((frame) => frame.includes(draft("[x]a", 4)))
setup.mockInput.pressKey("a", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(draft("[x]a", 0)))
setup.mockInput.pressKey("F3")
await setup.waitForFrame((frame) => frame.includes(draft("[x]a[x]", 7)))
await setup.mockInput.typeText("b")
await setup.waitForFrame((frame) => frame.includes(draft("[x]a[x]b", 8)))
setup.mockInput.pressKey("a", { ctrl: true })
setup.mockInput.pressArrow("right", { shift: true })
setup.mockInput.pressArrow("right", { shift: true })
await setup.waitForFrame((frame) => frame.includes(draft("[x]a[x]b", 0, 3)))
setup.mockInput.pressKey("F3")
await setup.waitForFrame((frame) => frame.includes(draft("[x]a[x]b[x]", 11)))
setup.mockInput.pressKey("-", { ctrl: true })
await setup.waitFor(() => composer(setup) === "[x]a[x]b")
})
test("append leaves empty text alone and rejects values that are not strings", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.mockInput.typeText("abc")
setup.mockInput.pressArrow("left")
await setup.waitForFrame((frame) => frame.includes(draft("abc", 2)))
setup.mockInput.pressKey("F4")
await setup.waitForFrame((frame) => frame.includes("empty true"))
expect(setup.captureCharFrame()).toContain(draft("abc", 2))
setup.mockInput.pressKey("F1")
await setup.waitForFrame((frame) => frame.includes("untyped threw TypeError"))
expect(composer(setup)).toBe("abc")
})
test("a multiline append uses newlines and grows the composer", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.mockInput.typeText("first")
await setup.waitFor(() => composer(setup) === "first")
expect(setup.renderer.currentFocusedEditor?.height).toBe(1)
setup.mockInput.pressKey("F12")
await setup.waitForFrame((frame) => frame.includes("lines true"))
expect(composer(setup)).toBe("first\nsecond\nthird")
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.height === 3)
const rows = setup.captureCharFrame().split("\n")
const top = rows.findIndex((row) => row.includes("┃ first"))
expect(rows.slice(top, top + 3).map((row) => row.trim())).toEqual(["┃ first", "┃ second", "┃ third"])
})
test("append keeps pasted and agent attachments through submit", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.pasteBracketedText("line one\nline two\nline three")
await setup.waitFor(() => composer(setup) === "[Pasted ~3 lines] ")
await setup.mockInput.typeText("ask @rev")
await setup.waitForFrame((frame) => frame.includes("@reviewer"))
setup.mockInput.pressEnter()
await setup.waitFor(() => composer(setup) === "[Pasted ~3 lines] ask @reviewer ")
setup.mockInput.pressKey("F3")
await setup.waitFor(() => composer(setup) === "[Pasted ~3 lines] ask @reviewer [x]")
setup.mockInput.pressEnter()
await setup.waitFor(() => run.prompts.length === 1)
expect(run.prompts[0]).toMatchObject({
text: "line one\nline two\nline three ask @reviewer [x]",
agents: [{ name: "reviewer", mention: { text: "@reviewer" } }],
})
})
test("an append followed by navigation in the same command is stashed with the draft", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.mockInput.typeText("draft")
await setup.waitFor(() => composer(setup) === "draft")
const before = setup.renderer.currentFocusedEditor
setup.mockInput.pressKey("F5")
await setup.waitFor(() => setup.renderer.currentFocusedEditor !== before && composer(setup) === "draft kept")
await setup.waitForFrame((frame) => frame.includes(draft("draft kept", 10)))
})
test("without a mounted composer current() is undefined and append returns false", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.mockInput.typeText("home")
await setup.waitForFrame((frame) => frame.includes(draft("home", 4)))
setup.mockInput.pressKey("F6")
await setup.waitForFrame(
(frame) => frame.includes("Away route") && frame.includes("away false") && frame.includes("draft null"),
)
setup.mockInput.pressKey("F3")
await setup.waitForFrame((frame) => frame.includes("append false"))
setup.mockInput.pressKey("F7")
await setup.waitForFrame((frame) => frame.includes(draft("home", 4)))
})
test("a disabled composer still accepts appends", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin, { forms: true })
const setup = run.setup
await setup.waitForFrame((frame) => frame.includes("Input requested") && frame.includes(draft("", 0)))
setup.mockInput.pressKey("F3")
await setup.waitForFrame((frame) => frame.includes("append true") && frame.includes(draft("[x]", 3)))
})
test("a context from an ended activation can no longer append", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.mockInput.typeText("keep")
setup.mockInput.pressKey("F11")
await setup.waitForFrame((frame) => frame.includes("first true"))
expect(composer(setup)).toBe("keep[x]")
await Bun.write(plugin.entry, (await Bun.file(plugin.entry).text()).replace("generation 1", "generation 2"))
await setup.waitForFrame((frame) => frame.includes("Prompt fixture ready generation 2"))
setup.mockInput.pressKey("F11")
await setup.waitForFrame((frame) => frame.includes("first false"))
expect(composer(setup)).toBe("keep[x]")
setup.mockInput.pressKey("F3")
await setup.waitFor(() => composer(setup) === "keep[x][x]")
})
test("plugin commands clear and submit the composer through keymap dispatch", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("clear me")
await setup.waitFor(() => composer(setup) === "clear me")
setup.mockInput.pressKey("F8")
await setup.waitFor(() => composer(setup) === "")
await setup.mockInput.typeText("send me")
await setup.waitFor(() => composer(setup) === "send me")
setup.mockInput.pressKey("F9")
await setup.waitFor(() => run.prompts.length === 1)
expect(run.prompts[0]).toMatchObject({ text: "send me" })
})
test("append closes completion so the same command can submit", async () => {
await using plugin = await copyPlugin()
await using run = await launch(plugin)
const setup = run.setup
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("ask @rev")
await setup.waitForFrame((frame) => frame.includes("@reviewer"))
setup.mockInput.pressKey("F10")
await setup.waitFor(() => run.prompts.length === 1)
expect(run.prompts[0]).toMatchObject({ text: "ask @rev now", agents: [] })
})
async function launch(plugin: { directory: string }, input: { forms?: boolean } = {}) {
const prompts: unknown[] = []
const sessions = new Map<string, object>()
const setup = await createAppFixture({
config: { animations: false, plugins: [plugin.directory] },
fetch: async (url, request) => {
if (url.pathname === "/api/agent")
return json({
location,
data: [
{ id: "build", mode: "primary", hidden: false, permissions: [] },
{ id: "reviewer", mode: "subagent", hidden: false, permissions: [] },
],
})
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
if (url.pathname === "/api/fs/find") return json({ location, data: [] })
if (url.pathname === "/api/form" && input.forms)
return json({
location,
data: [
{
id: "frm_global",
sessionID: "global",
title: "Input requested",
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
},
],
})
if (url.pathname === "/api/session" && request.method === "POST") {
const record: { id: string } = await request.json()
const session = {
...record,
location: { directory },
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
sessions.set(record.id, session)
return json({ data: session })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
prompts.push(await request.json())
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (/^\/api\/session\/[^/]+\/(agent|model)$/.test(url.pathname)) return new Response(null, { status: 204 })
if (/^\/api\/session\/[^/]+$/.test(url.pathname)) {
const session = sessions.get(url.pathname.split("/")[3] ?? "")
if (!session) return json({ message: "not found" }, { status: 404 })
return json({ data: session })
}
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes(draft("", 0)))
return { setup, prompts, [Symbol.asyncDispose]: setup[Symbol.asyncDispose] }
}
function draft(text: string, start: number, end = start, mode = "normal") {
return `draft ${JSON.stringify({ text, selection: { start, end }, mode })}`
}
function composer(setup: Fixture) {
return setup.renderer.currentFocusedEditor?.plainText
}
async function copyPlugin() {
const root = await tmpdir()
const directory = path.join(root.path, "prompt-api")
const entry = path.join(directory, "tui.tsx")
await mkdir(directory)
await symlink(path.join(import.meta.dir, "../node_modules"), path.join(directory, "node_modules"))
await Bun.write(entry, Bun.file(path.join(import.meta.dir, "fixture/plugin/prompt-api/tui.tsx")))
return { directory, entry, [Symbol.asyncDispose]: root[Symbol.asyncDispose] }
}
@@ -477,6 +477,34 @@ context.keymap.layer(() => ({
}))
```
## Prompt
Read the composer draft and append to it. `current()` is reactive when read in a Solid computation. It returns
`undefined` when no composer is mounted, for example on a plugin route or while a permission prompt replaces the
composer.
```ts
const draft = context.ui.prompt.current()
// { text: "Review @src/app.ts", selection: { start: 18, end: 18 }, mode: "normal" }
context.ui.prompt.append(" Check the tests too.")
```
`text` is the composer text as shown. Attachments and collapsed pastes appear as their labels, such as `@src/app.ts`
or `[Pasted ~3 lines]`. `selection` holds UTF-16 indices into `text`, the units `String.prototype.slice` uses, and
`start === end` is the caret.
`append` adds text at the end of the composer and moves the cursor after it. Existing attachments are unchanged, line
endings become `\n`, and one undo removes the appended text. It returns `false` when no composer is mounted, or once
the plugin that owns this `context` is disabled, reloaded, or fails setup.
Clear or submit the composer with the built-in commands. `prompt.submit` only submits while the composer has keyboard
focus, for example from a key binding pressed while typing.
```ts
context.keymap.dispatch("prompt.clear")
context.keymap.dispatch("prompt.submit")
```
## Slots
Slots insert or replace JSX at `app`, `home.footer`, `home.footer.status`, `prompt.footer`, `prompt.footer.status`,