mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 22:46:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab8a5f225c |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,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}` })
|
||||
},
|
||||
})
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user