mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 06:56:16 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a674a3b8 | ||
|
|
4f9c50a417 | ||
|
|
9c2adeda28 | ||
|
|
0a46301e36 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -487,21 +487,6 @@ 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
|
||||
@@ -552,7 +537,6 @@ 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,7 +31,6 @@ export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
visible: false | "reference" | "command" | "directory"
|
||||
completeQueueableCommand: () => boolean
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export type AutocompleteOption = {
|
||||
@@ -778,9 +777,6 @@ export function Autocomplete(props: {
|
||||
select()
|
||||
return true
|
||||
},
|
||||
close() {
|
||||
hide()
|
||||
},
|
||||
onInput(value) {
|
||||
if (dismissedValue() === value) return
|
||||
setDismissedValue(undefined)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
CliRenderEvents,
|
||||
RGBA,
|
||||
TextareaRenderable,
|
||||
MouseEvent,
|
||||
@@ -60,7 +59,6 @@ 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,
|
||||
@@ -97,8 +95,6 @@ export type PromptRef = {
|
||||
blur(): void
|
||||
focus(): void
|
||||
submit(): void
|
||||
draft(): PromptDraft | undefined
|
||||
append(text: string): boolean
|
||||
}
|
||||
|
||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||
@@ -315,10 +311,6 @@ 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() {
|
||||
@@ -491,9 +483,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data, changed)
|
||||
}
|
||||
if (content?.mime === "text/plain") pasteInputText(content.data)
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -705,31 +695,6 @@ 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() {
|
||||
@@ -906,12 +871,6 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function syncFromInput() {
|
||||
setStore("prompt", "text", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
setCursorVersion((value) => value + 1)
|
||||
}
|
||||
|
||||
const stashCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
@@ -1149,7 +1108,10 @@ 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) syncFromInput()
|
||||
if (input && !input.isDestroyed && input.plainText !== store.prompt.text) {
|
||||
setStore("prompt", "text", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
@@ -1470,12 +1432,23 @@ export function Prompt(props: PromptProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string, changed: () => boolean) {
|
||||
function pasteInputText(text: string) {
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste.
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const attachments = await resolvePastedAttachments(pastedContent, terminalEnvironment.platform)
|
||||
if (changed()) return
|
||||
if (attachments) {
|
||||
// Plain text inserts now so keys from the same input batch, such as punctuation
|
||||
// that commits a multi-character IME candidate, land after it.
|
||||
const pending = resolvePastedAttachments(normalizedText, terminalEnvironment.platform)
|
||||
if (!pending) {
|
||||
insertPastedText(normalizedText)
|
||||
return
|
||||
}
|
||||
void enqueuePaste(async (changed) => {
|
||||
const attachments = await pending
|
||||
if (changed()) return
|
||||
if (!attachments) {
|
||||
insertPastedText(normalizedText)
|
||||
return
|
||||
}
|
||||
attachments.forEach((attachment) => {
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${attachment.filename || "image"}]`)
|
||||
@@ -1483,9 +1456,11 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
pasteAttachment(attachment)
|
||||
})
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function insertPastedText(normalizedText: string) {
|
||||
const pastedContent = normalizedText.trim()
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||
@@ -1787,8 +1762,11 @@ export function Prompt(props: PromptProps) {
|
||||
maxHeight={maxHeight()}
|
||||
cursorStyle={config.cursor}
|
||||
onContentChange={() => {
|
||||
syncFromInput()
|
||||
auto()?.onInput(input.plainText)
|
||||
const value = input.plainText
|
||||
setStore("prompt", "text", value)
|
||||
auto()?.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
setCursorVersion((value) => value + 1)
|
||||
}}
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
@@ -1809,11 +1787,6 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize line endings at the boundary
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
|
||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||
if (event.bytes.byteLength === 0) {
|
||||
@@ -1821,11 +1794,10 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Once we cross an async boundary below, the terminal may perform its
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
// pasteInputText may insert after an async attachment check, so suppress
|
||||
// the default paste and let it handle insertion.
|
||||
event.preventDefault()
|
||||
|
||||
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
|
||||
pasteInputText(decodePasteBytes(event.bytes))
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
@@ -1836,7 +1808,6 @@ 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
|
||||
|
||||
@@ -26,15 +26,20 @@ export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMEN
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolvePastedAttachments(text: string, platform: string) {
|
||||
// Attachments are recognized by extension, so plain text resolves to undefined synchronously.
|
||||
export function resolvePastedAttachments(text: string, platform: string) {
|
||||
const pastedContent = text.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, platform)
|
||||
if (/^(https?):\/\//.test(filepath)) return undefined
|
||||
const filepaths = parsePastedFilepaths(pastedContent, platform)
|
||||
if (![filepath, ...filepaths].some((file) => path.extname(file).toLowerCase() in mimeTypes)) return undefined
|
||||
return readPastedAttachments(filepath, filepaths)
|
||||
}
|
||||
|
||||
async function readPastedAttachments(filepath: string, filepaths: string[]) {
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
const attachments = attachment ? [{ filepath, attachment }] : []
|
||||
if (!attachment) {
|
||||
const filepaths = parsePastedFilepaths(pastedContent, platform)
|
||||
if (filepaths.length <= 1) return undefined
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
for (const candidate of filepaths) {
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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: () => {
|
||||
const [current, setCurrent] = createSignal<PromptRef>()
|
||||
let current: PromptRef | undefined
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current()
|
||||
return current
|
||||
},
|
||||
bind(ref: PromptRef) {
|
||||
setCurrent(ref)
|
||||
return () => setCurrent((value) => (value === ref ? undefined : value))
|
||||
set(ref: PromptRef | undefined) {
|
||||
current = ref
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ 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>
|
||||
@@ -83,7 +82,6 @@ export function usePluginHost() {
|
||||
sessionTabs: useSessionTabs(),
|
||||
panel: useOptionalPanel(),
|
||||
local: useLocal(),
|
||||
prompt: usePromptRef(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,14 +295,6 @@ 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,6 +3,7 @@ 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"
|
||||
@@ -23,6 +24,7 @@ const placeholder = {
|
||||
|
||||
export function Home() {
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
@@ -50,6 +52,7 @@ 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,6 +375,7 @@ 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)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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}` })
|
||||
},
|
||||
})
|
||||
@@ -1,306 +0,0 @@
|
||||
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] }
|
||||
}
|
||||
@@ -57,6 +57,15 @@ describe("prompt local attachments", () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("resolves pastes without attachment paths synchronously", () => {
|
||||
for (const input of ["你好", "plain\r\ntext", "https://example.com/image.png", "/tmp/notes.txt", ""]) {
|
||||
expect(resolvePastedAttachments(input, "linux")).toBeUndefined()
|
||||
}
|
||||
for (const input of ["/tmp/image.PNG", "file:///tmp/file.pdf", "/tmp/one.png /tmp/two.webp"]) {
|
||||
expect(resolvePastedAttachments(input, "linux")).toBeInstanceOf(Promise)
|
||||
}
|
||||
})
|
||||
|
||||
test("reads SVG attachments as text", async () => {
|
||||
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
||||
type: "text",
|
||||
|
||||
@@ -477,34 +477,6 @@ 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