Pasting into the prompt textarea left its layout stale until the next edit, so the visible content did not reflect the pasted text. Mark the layout dirty on paste and notify the content-change handler once the renderer is idle so the prompt updates immediately.
1149 lines
28 KiB
TypeScript
1149 lines
28 KiB
TypeScript
// Prompt textarea component and its state machine for direct interactive mode.
|
|
//
|
|
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
|
// and `@` autocomplete for files, subagents, and MCP resources.
|
|
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
|
// while the footer view renders the current menu state below it.
|
|
/** @jsxImportSource @opentui/solid */
|
|
import { pathToFileURL } from "bun"
|
|
import {
|
|
StyledText,
|
|
bg,
|
|
fg,
|
|
type KeyBinding,
|
|
type KeyEvent,
|
|
type TextareaRenderable,
|
|
} from "@opentui/core"
|
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
|
import fuzzysort from "fuzzysort"
|
|
import path from "path"
|
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
|
import * as Locale from "@/util/locale"
|
|
import {
|
|
createPromptHistory,
|
|
displayCharAt,
|
|
displaySlice,
|
|
isExitCommand,
|
|
mentionTriggerIndex,
|
|
isNewCommand,
|
|
movePromptHistory,
|
|
promptCycle,
|
|
promptHit,
|
|
promptInfo,
|
|
promptKeys,
|
|
pushPromptHistory,
|
|
} from "./prompt.shared"
|
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
|
import type { RunFooterTheme } from "./theme"
|
|
import type { FooterKeybinds, FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource } from "./types"
|
|
|
|
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
|
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
|
|
|
export const TEXTAREA_MIN_ROWS = 1
|
|
export const TEXTAREA_MAX_ROWS = 6
|
|
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
|
|
|
|
export const HINT_BREAKPOINTS = {
|
|
send: 50,
|
|
newline: 66,
|
|
history: 80,
|
|
command: 95,
|
|
}
|
|
|
|
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
|
|
|
type Auto = RunFooterMenuItem & {
|
|
kind: "mention"
|
|
value: string
|
|
part: Mention
|
|
directory?: boolean
|
|
}
|
|
|
|
type SlashOption = RunFooterMenuItem & {
|
|
kind: "slash"
|
|
name: string
|
|
}
|
|
|
|
type PromptOption = Auto | SlashOption
|
|
|
|
type MenuMode = false | "mention" | "slash"
|
|
|
|
type PromptInput = {
|
|
directory: string
|
|
findFiles: (query: string) => Promise<string[]>
|
|
agents: Accessor<RunAgent[]>
|
|
resources: Accessor<RunResource[]>
|
|
commands: Accessor<RunCommand[] | undefined>
|
|
keybinds: FooterKeybinds
|
|
state: Accessor<FooterState>
|
|
view: Accessor<string>
|
|
prompt: Accessor<boolean>
|
|
width: Accessor<number>
|
|
theme: Accessor<RunFooterTheme>
|
|
history?: RunPrompt[]
|
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
|
onCycle: () => void
|
|
onInterrupt: () => boolean
|
|
onInputClear: () => void
|
|
onExitRequest?: () => boolean
|
|
onExit: () => void
|
|
onRows: (rows: number) => void
|
|
onStatus: (text: string) => void
|
|
}
|
|
|
|
export type PromptState = {
|
|
placeholder: Accessor<StyledText | string>
|
|
bindings: Accessor<KeyBinding[]>
|
|
visible: Accessor<boolean>
|
|
options: Accessor<PromptOption[]>
|
|
selected: Accessor<number>
|
|
offset: Accessor<number>
|
|
rows: Accessor<number>
|
|
requestExit: () => boolean
|
|
onSubmit: () => void
|
|
submitText: (text: string) => void
|
|
onKeyDown: (event: KeyEvent) => void
|
|
onContentChange: () => void
|
|
replaceDraft: (text: string) => void
|
|
bind: (area?: TextareaRenderable) => void
|
|
}
|
|
|
|
function clamp(rows: number): number {
|
|
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
|
}
|
|
|
|
function clonePrompt(prompt: RunPrompt): RunPrompt {
|
|
return {
|
|
text: prompt.text,
|
|
parts: structuredClone(prompt.parts),
|
|
}
|
|
}
|
|
|
|
function removeLineRange(input: string) {
|
|
const hash = input.lastIndexOf("#")
|
|
return hash === -1 ? input : input.slice(0, hash)
|
|
}
|
|
|
|
function extractLineRange(input: string) {
|
|
const hash = input.lastIndexOf("#")
|
|
if (hash === -1) {
|
|
return { base: input }
|
|
}
|
|
|
|
const base = input.slice(0, hash)
|
|
const line = input.slice(hash + 1)
|
|
const match = line.match(/^(\d+)(?:-(\d*))?$/)
|
|
if (!match) {
|
|
return { base }
|
|
}
|
|
|
|
const start = Number(match[1])
|
|
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
|
|
return { base, line: { start, end } }
|
|
}
|
|
|
|
function slashHead(text: string) {
|
|
if (!text.startsWith("/")) {
|
|
return
|
|
}
|
|
|
|
for (let i = 1; i < text.length; i++) {
|
|
switch (text[i]) {
|
|
case " ":
|
|
case "\t":
|
|
case "\n":
|
|
return { name: text.slice(1, i), arguments: text.slice(i + 1), end: i }
|
|
}
|
|
}
|
|
|
|
return { name: text.slice(1), arguments: "", end: text.length }
|
|
}
|
|
|
|
function slashQuery(text: string, cursor: number) {
|
|
const head = slashHead(text.slice(0, cursor))
|
|
if (!head || head.end !== cursor) {
|
|
return
|
|
}
|
|
|
|
return head.name
|
|
}
|
|
|
|
function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
|
const head = slashHead(text)
|
|
if (!head || head.name.length === 0) {
|
|
return { type: "none" as const }
|
|
}
|
|
|
|
if (!commands) {
|
|
return { type: "pending" as const }
|
|
}
|
|
|
|
if (!commands.some((item) => item.name === head.name)) {
|
|
return { type: "none" as const }
|
|
}
|
|
|
|
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
|
|
}
|
|
|
|
export function hintFlags(width: number) {
|
|
return {
|
|
send: width >= HINT_BREAKPOINTS.send,
|
|
newline: width >= HINT_BREAKPOINTS.newline,
|
|
history: width >= HINT_BREAKPOINTS.history,
|
|
command: width >= HINT_BREAKPOINTS.command,
|
|
}
|
|
}
|
|
|
|
export function RunPromptBody(props: {
|
|
theme: () => RunFooterTheme
|
|
placeholder: () => StyledText | string
|
|
bindings: () => KeyBinding[]
|
|
onSubmit: () => void
|
|
onKeyDown: (event: KeyEvent) => void
|
|
onContentChange: () => void
|
|
bind: (area?: TextareaRenderable) => void
|
|
}) {
|
|
const renderer = useRenderer()
|
|
let area: TextareaRenderable | undefined
|
|
let pasteTick: ReturnType<typeof setTimeout> | undefined
|
|
|
|
const refreshPasteLayout = () => {
|
|
if (pasteTick) {
|
|
clearTimeout(pasteTick)
|
|
}
|
|
|
|
pasteTick = setTimeout(() => {
|
|
pasteTick = undefined
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
// Paste can leave the textarea layout stale until the next edit.
|
|
area.getLayoutNode().markDirty()
|
|
renderer.requestRender()
|
|
void renderer
|
|
.idle()
|
|
.then(() => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
props.onContentChange()
|
|
})
|
|
.catch(() => {})
|
|
}, 0)
|
|
}
|
|
|
|
onMount(() => {
|
|
props.bind(area)
|
|
})
|
|
|
|
onCleanup(() => {
|
|
if (pasteTick) {
|
|
clearTimeout(pasteTick)
|
|
}
|
|
props.bind(undefined)
|
|
})
|
|
|
|
return (
|
|
<box id="run-direct-footer-prompt" width="100%">
|
|
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
|
|
<textarea
|
|
id="run-direct-footer-composer"
|
|
width="100%"
|
|
minHeight={TEXTAREA_MIN_ROWS}
|
|
maxHeight={TEXTAREA_MAX_ROWS}
|
|
wrapMode="word"
|
|
placeholder={props.placeholder()}
|
|
placeholderColor={props.theme().muted}
|
|
textColor={props.theme().text}
|
|
focusedTextColor={props.theme().text}
|
|
backgroundColor={props.theme().surface}
|
|
focusedBackgroundColor={props.theme().surface}
|
|
cursorColor={props.theme().text}
|
|
keyBindings={props.bindings()}
|
|
onSubmit={props.onSubmit}
|
|
onKeyDown={props.onKeyDown}
|
|
onPaste={() => {
|
|
refreshPasteLayout()
|
|
}}
|
|
onContentChange={props.onContentChange}
|
|
ref={(next) => {
|
|
area = next
|
|
}}
|
|
/>
|
|
</box>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
export function createPromptState(input: PromptInput): PromptState {
|
|
const keys = createMemo(() => promptKeys(input.keybinds))
|
|
const bindings = createMemo(() => keys().bindings)
|
|
const placeholder = createMemo(() => {
|
|
if (!input.state().first) {
|
|
return ""
|
|
}
|
|
|
|
return new StyledText([
|
|
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
|
|
])
|
|
})
|
|
|
|
let history = createPromptHistory(input.history)
|
|
let draft: RunPrompt = { text: "", parts: [] }
|
|
let stash: RunPrompt = { text: "", parts: [] }
|
|
let area: TextareaRenderable | undefined
|
|
let leader = false
|
|
let timeout: NodeJS.Timeout | undefined
|
|
let tick = false
|
|
let prev = input.view()
|
|
let type = 0
|
|
let parts: Mention[] = []
|
|
let marks = new Map<number, number>()
|
|
|
|
const [mode, setMode] = createSignal<MenuMode>(false)
|
|
const [at, setAt] = createSignal(0)
|
|
const [query, setQuery] = createSignal("")
|
|
const visible = createMemo(() => mode() !== false)
|
|
|
|
const width = createMemo(() => Math.max(20, input.width() - 8))
|
|
const agents = createMemo<Auto[]>(() => {
|
|
return input
|
|
.agents()
|
|
.filter((item) => !item.hidden && item.mode !== "primary")
|
|
.map((item) => ({
|
|
kind: "mention",
|
|
display: "@" + item.name,
|
|
value: item.name,
|
|
part: {
|
|
type: "agent",
|
|
name: item.name,
|
|
source: {
|
|
start: 0,
|
|
end: 0,
|
|
value: "",
|
|
},
|
|
},
|
|
}))
|
|
})
|
|
const resources = createMemo<Auto[]>(() => {
|
|
return input.resources().map((item) => ({
|
|
kind: "mention",
|
|
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
|
|
value: item.name,
|
|
description: item.description,
|
|
part: {
|
|
type: "file",
|
|
mime: item.mimeType ?? "text/plain",
|
|
filename: item.name,
|
|
url: item.uri,
|
|
source: {
|
|
type: "resource",
|
|
clientName: item.client,
|
|
uri: item.uri,
|
|
text: {
|
|
start: 0,
|
|
end: 0,
|
|
value: "",
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
})
|
|
const [files] = createResource(
|
|
query,
|
|
async (value) => {
|
|
if (!visible() || mode() !== "mention") {
|
|
return []
|
|
}
|
|
|
|
const next = extractLineRange(value)
|
|
const list = await input.findFiles(next.base)
|
|
return list
|
|
.sort((a, b) => {
|
|
const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
|
|
if (dir !== 0) {
|
|
return dir
|
|
}
|
|
|
|
const depth = a.split("/").length - b.split("/").length
|
|
if (depth !== 0) {
|
|
return depth
|
|
}
|
|
|
|
return a.localeCompare(b)
|
|
})
|
|
.map((item): Auto => {
|
|
const url = pathToFileURL(path.resolve(input.directory, item))
|
|
let filename = item
|
|
if (next.line && !item.endsWith("/")) {
|
|
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
|
url.searchParams.set("start", String(next.line.start))
|
|
if (next.line.end !== undefined) {
|
|
url.searchParams.set("end", String(next.line.end))
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: "mention",
|
|
display: Locale.truncateMiddle("@" + filename, width()),
|
|
value: filename,
|
|
directory: item.endsWith("/"),
|
|
part: {
|
|
type: "file",
|
|
mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
|
|
filename,
|
|
url: url.href,
|
|
source: {
|
|
type: "file",
|
|
path: item,
|
|
text: {
|
|
start: 0,
|
|
end: 0,
|
|
value: "",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
})
|
|
},
|
|
{ initialValue: [] as Auto[] },
|
|
)
|
|
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
|
|
const slashOptions = createMemo<SlashOption[]>(() => {
|
|
const builtins = [
|
|
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
|
|
{ kind: "slash", name: "exit", display: "/exit", description: "close direct mode" } satisfies SlashOption,
|
|
]
|
|
const hidden = new Set(builtins.map((item) => item.name))
|
|
return [
|
|
...(input.commands() ?? [])
|
|
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
|
|
.map(
|
|
(item) =>
|
|
({
|
|
kind: "slash",
|
|
name: item.name,
|
|
display: `/${item.name}${item.source === "mcp" ? ":mcp" : ""}`,
|
|
description: item.description,
|
|
}) satisfies SlashOption,
|
|
),
|
|
...builtins,
|
|
].sort((a, b) => a.display.localeCompare(b.display))
|
|
})
|
|
const options = createMemo<PromptOption[]>(() => {
|
|
const mixed: PromptOption[] = mode() === "slash" ? slashOptions() : mentionOptions()
|
|
if (!query()) {
|
|
return mixed
|
|
}
|
|
|
|
return fuzzysort
|
|
.go(removeLineRange(query()), mixed, {
|
|
keys: [(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(), "display", "description"],
|
|
})
|
|
.map((item) => item.obj)
|
|
})
|
|
const menu = createFooterMenuState({ count: () => options().length, limit: AUTOCOMPLETE_ROWS })
|
|
const popup = createMemo(() => {
|
|
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
|
|
})
|
|
|
|
const clear = () => {
|
|
leader = false
|
|
if (!timeout) {
|
|
return
|
|
}
|
|
|
|
clearTimeout(timeout)
|
|
timeout = undefined
|
|
}
|
|
|
|
const arm = () => {
|
|
clear()
|
|
leader = true
|
|
timeout = setTimeout(() => {
|
|
clear()
|
|
}, input.keybinds.leaderTimeout)
|
|
}
|
|
|
|
const hide = () => {
|
|
setMode(false)
|
|
setQuery("")
|
|
menu.reset()
|
|
}
|
|
|
|
const syncRows = () => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
input.onRows(clamp(area.virtualLineCount || 1) + popup())
|
|
}
|
|
|
|
const scheduleRows = () => {
|
|
if (tick) {
|
|
return
|
|
}
|
|
|
|
tick = true
|
|
queueMicrotask(() => {
|
|
tick = false
|
|
syncRows()
|
|
})
|
|
}
|
|
|
|
const syncParts = () => {
|
|
if (!area || area.isDestroyed || type === 0) {
|
|
return
|
|
}
|
|
|
|
const next: Mention[] = []
|
|
const map = new Map<number, number>()
|
|
for (const item of area.extmarks.getAllForTypeId(type)) {
|
|
const idx = marks.get(item.id)
|
|
if (idx === undefined) {
|
|
continue
|
|
}
|
|
|
|
const part = parts[idx]
|
|
if (!part) {
|
|
continue
|
|
}
|
|
|
|
const text = area.plainText.slice(item.start, item.end)
|
|
const prev =
|
|
part.type === "agent"
|
|
? (part.source?.value ?? "@" + part.name)
|
|
: (part.source?.text.value ?? "@" + (part.filename ?? ""))
|
|
if (text !== prev) {
|
|
continue
|
|
}
|
|
|
|
const copy = structuredClone(part)
|
|
if (copy.type === "agent") {
|
|
copy.source = {
|
|
start: item.start,
|
|
end: item.end,
|
|
value: text,
|
|
}
|
|
}
|
|
if (copy.type === "file" && copy.source?.text) {
|
|
copy.source.text.start = item.start
|
|
copy.source.text.end = item.end
|
|
copy.source.text.value = text
|
|
}
|
|
|
|
map.set(item.id, next.length)
|
|
next.push(copy)
|
|
}
|
|
|
|
const stale = map.size !== marks.size
|
|
parts = next
|
|
marks = map
|
|
if (stale) {
|
|
restoreParts(next)
|
|
}
|
|
}
|
|
|
|
const clearParts = () => {
|
|
if (area && !area.isDestroyed) {
|
|
area.extmarks.clear()
|
|
}
|
|
parts = []
|
|
marks = new Map()
|
|
}
|
|
|
|
const restoreParts = (value: RunPromptPart[]) => {
|
|
clearParts()
|
|
parts = value
|
|
.filter((item): item is Mention => item.type === "file" || item.type === "agent")
|
|
.map((item) => structuredClone(item))
|
|
if (!area || area.isDestroyed || type === 0) {
|
|
return
|
|
}
|
|
|
|
const box = area
|
|
parts.forEach((item, idx) => {
|
|
const start = item.type === "agent" ? item.source?.start : item.source?.text.start
|
|
const end = item.type === "agent" ? item.source?.end : item.source?.text.end
|
|
if (start === undefined || end === undefined) {
|
|
return
|
|
}
|
|
|
|
const id = box.extmarks.create({
|
|
start,
|
|
end,
|
|
virtual: true,
|
|
typeId: type,
|
|
})
|
|
marks.set(id, idx)
|
|
})
|
|
}
|
|
|
|
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
|
|
draft = clonePrompt(value)
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
hide()
|
|
area.setText(value.text)
|
|
restoreParts(value.parts)
|
|
area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
|
|
scheduleRows()
|
|
area.focus()
|
|
}
|
|
|
|
const resetDraft = () => {
|
|
if (area && !area.isDestroyed) {
|
|
area.setText("")
|
|
}
|
|
|
|
clearParts()
|
|
hide()
|
|
draft = { text: "", parts: [] }
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
scheduleRows()
|
|
area.focus()
|
|
}
|
|
|
|
const replaceDraft = (text: string) => {
|
|
draft = { text, parts: [] }
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
hide()
|
|
area.setText(text)
|
|
clearParts()
|
|
draft = { text: area.plainText, parts: [] }
|
|
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
|
|
scheduleRows()
|
|
area.focus()
|
|
}
|
|
|
|
const refresh = () => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
const cursor = area.cursorOffset
|
|
const text = area.plainText
|
|
const slash = slashQuery(text, cursor)
|
|
if (mode() === "slash") {
|
|
if (slash === undefined) {
|
|
hide()
|
|
return
|
|
}
|
|
|
|
setAt(0)
|
|
setQuery(slash)
|
|
return
|
|
}
|
|
|
|
if (slash !== undefined) {
|
|
setAt(0)
|
|
menu.reset()
|
|
setMode("slash")
|
|
setQuery(slash)
|
|
return
|
|
}
|
|
|
|
if (visible() && mode() === "mention") {
|
|
const query = displaySlice(text, at(), cursor)
|
|
if (cursor <= at() || /\s/.test(query)) {
|
|
hide()
|
|
return
|
|
}
|
|
|
|
setQuery(displaySlice(text, at() + 1, cursor))
|
|
return
|
|
}
|
|
|
|
if (cursor === 0) {
|
|
return
|
|
}
|
|
|
|
const idx = mentionTriggerIndex(text, cursor)
|
|
if (idx !== undefined) {
|
|
setAt(idx)
|
|
menu.reset()
|
|
setMode("mention")
|
|
setQuery(displaySlice(text, idx + 1, cursor))
|
|
}
|
|
}
|
|
|
|
const bind = (next?: TextareaRenderable) => {
|
|
if (area === next) {
|
|
return
|
|
}
|
|
|
|
if (area && !area.isDestroyed) {
|
|
area.off("line-info-change", scheduleRows)
|
|
}
|
|
|
|
area = next
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
if (type === 0) {
|
|
type = area.extmarks.registerType("run-direct-prompt-part")
|
|
}
|
|
area.on("line-info-change", scheduleRows)
|
|
queueMicrotask(() => {
|
|
if (!area || area.isDestroyed || !input.prompt()) {
|
|
return
|
|
}
|
|
|
|
restore(draft)
|
|
refresh()
|
|
})
|
|
}
|
|
|
|
const syncDraft = () => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
syncParts()
|
|
draft = {
|
|
text: area.plainText,
|
|
parts: structuredClone(parts),
|
|
}
|
|
}
|
|
|
|
const push = (value: RunPrompt) => {
|
|
history = pushPromptHistory(history, value)
|
|
}
|
|
|
|
const move = (dir: -1 | 1, event: KeyEvent) => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
if (history.index === null && dir === -1) {
|
|
stash = clonePrompt(draft)
|
|
}
|
|
|
|
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
|
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
|
return
|
|
}
|
|
|
|
history = next.state
|
|
const value =
|
|
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
|
|
restore(value, next.cursor)
|
|
event.preventDefault()
|
|
}
|
|
|
|
const cycle = (event: KeyEvent): boolean => {
|
|
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
|
if (!next.consume) {
|
|
return false
|
|
}
|
|
|
|
if (next.clear) {
|
|
clear()
|
|
}
|
|
|
|
if (next.arm) {
|
|
arm()
|
|
}
|
|
|
|
if (next.cycle) {
|
|
input.onCycle()
|
|
}
|
|
|
|
event.preventDefault()
|
|
return true
|
|
}
|
|
|
|
const requestExit = () => {
|
|
const text = area && !area.isDestroyed ? area.plainText : draft.text
|
|
if (input.prompt() && text.length > 0) {
|
|
input.onInputClear()
|
|
resetDraft()
|
|
return true
|
|
}
|
|
|
|
return input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
|
}
|
|
|
|
const cancelAutocomplete = () => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
const cursor = area.cursorOffset
|
|
const startOffset = mode() === "slash" ? 0 : at()
|
|
area.cursorOffset = startOffset
|
|
const start = area.logicalCursor
|
|
area.cursorOffset = cursor
|
|
const end = area.logicalCursor
|
|
area.deleteRange(start.row, start.col, end.row, end.col)
|
|
area.cursorOffset = startOffset
|
|
hide()
|
|
syncDraft()
|
|
scheduleRows()
|
|
area.focus()
|
|
}
|
|
|
|
const select = (item?: PromptOption) => {
|
|
const next = item ?? options()[menu.selected()]
|
|
if (!next || !area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
if (next.kind === "slash") {
|
|
const text = `/${next.name} `
|
|
const cursor = area.cursorOffset
|
|
|
|
area.cursorOffset = 0
|
|
const start = area.logicalCursor
|
|
area.cursorOffset = cursor
|
|
const end = area.logicalCursor
|
|
|
|
area.deleteRange(start.row, start.col, end.row, end.col)
|
|
area.insertText(text)
|
|
area.cursorOffset = Bun.stringWidth(text)
|
|
hide()
|
|
syncDraft()
|
|
scheduleRows()
|
|
area.focus()
|
|
return
|
|
}
|
|
|
|
const cursor = area.cursorOffset
|
|
const tail = displayCharAt(area.plainText, cursor)
|
|
const append = "@" + next.value + (tail === " " ? "" : " ")
|
|
area.cursorOffset = at()
|
|
const start = area.logicalCursor
|
|
area.cursorOffset = cursor
|
|
const end = area.logicalCursor
|
|
area.deleteRange(start.row, start.col, end.row, end.col)
|
|
area.insertText(append)
|
|
|
|
const text = "@" + next.value
|
|
const startOffset = at()
|
|
const endOffset = startOffset + Bun.stringWidth(text)
|
|
const part = structuredClone(next.part)
|
|
if (part.type === "agent") {
|
|
part.source = {
|
|
start: startOffset,
|
|
end: endOffset,
|
|
value: text,
|
|
}
|
|
}
|
|
if (part.type === "file" && part.source?.text) {
|
|
part.source.text.start = startOffset
|
|
part.source.text.end = endOffset
|
|
part.source.text.value = text
|
|
}
|
|
|
|
if (part.type === "file") {
|
|
const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
|
|
if (prev !== -1) {
|
|
const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
|
|
if (mark !== undefined) {
|
|
area.extmarks.delete(mark)
|
|
}
|
|
parts = parts.filter((_, idx) => idx !== prev)
|
|
marks = new Map(
|
|
[...marks.entries()]
|
|
.filter((item) => item[0] !== mark)
|
|
.map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
|
|
)
|
|
}
|
|
}
|
|
|
|
const id = area.extmarks.create({
|
|
start: startOffset,
|
|
end: endOffset,
|
|
virtual: true,
|
|
typeId: type,
|
|
})
|
|
marks.set(id, parts.length)
|
|
parts.push(part)
|
|
hide()
|
|
syncDraft()
|
|
scheduleRows()
|
|
area.focus()
|
|
}
|
|
|
|
const expand = () => {
|
|
const next = options()[menu.selected()]
|
|
if (!next || next.kind !== "mention" || !next.directory || !area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
const cursor = area.cursorOffset
|
|
area.cursorOffset = at()
|
|
const start = area.logicalCursor
|
|
area.cursorOffset = cursor
|
|
const end = area.logicalCursor
|
|
area.deleteRange(start.row, start.col, end.row, end.col)
|
|
area.insertText("@" + next.value)
|
|
syncDraft()
|
|
refresh()
|
|
}
|
|
|
|
const onKeyDown = (event: KeyEvent) => {
|
|
const key = promptInfo(event)
|
|
if (visible()) {
|
|
const name = event.name.toLowerCase()
|
|
const ctrl = event.ctrl && !event.meta && !event.shift
|
|
if (name === "up" || (ctrl && name === "p")) {
|
|
event.preventDefault()
|
|
if (options().length > 0) {
|
|
menu.move(-1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (name === "down" || (ctrl && name === "n")) {
|
|
event.preventDefault()
|
|
if (options().length > 0) {
|
|
menu.move(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (name === "escape") {
|
|
event.preventDefault()
|
|
cancelAutocomplete()
|
|
return
|
|
}
|
|
|
|
if (name === "return") {
|
|
if (mode() === "slash" && options().length === 0) {
|
|
hide()
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
select()
|
|
return
|
|
}
|
|
|
|
if (name === "tab") {
|
|
if (mode() === "slash" && options().length === 0) {
|
|
hide()
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
const item = options()[menu.selected()]
|
|
if (item?.kind === "mention" && item.directory) {
|
|
expand()
|
|
return
|
|
}
|
|
|
|
select()
|
|
return
|
|
}
|
|
}
|
|
|
|
if (promptHit(keys().clear, key)) {
|
|
const handled = requestExit()
|
|
if (handled) {
|
|
event.preventDefault()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (promptHit(keys().interrupts, key)) {
|
|
if (input.onInterrupt()) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
}
|
|
|
|
if (cycle(event)) {
|
|
return
|
|
}
|
|
|
|
const up = promptHit(keys().previous, key)
|
|
const down = promptHit(keys().next, key)
|
|
if (!up && !down) {
|
|
return
|
|
}
|
|
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
const dir = up ? -1 : 1
|
|
const endOffset = Bun.stringWidth(area.plainText)
|
|
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) {
|
|
move(dir, event)
|
|
return
|
|
}
|
|
|
|
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
|
area.cursorOffset = 0
|
|
}
|
|
|
|
const end =
|
|
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
|
? area.height - 1
|
|
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
|
|
if (dir === 1 && area.visualCursor.visualRow === end) {
|
|
area.cursorOffset = endOffset
|
|
}
|
|
}
|
|
|
|
useKeyboard((event) => {
|
|
if (input.prompt()) {
|
|
return
|
|
}
|
|
|
|
if (input.view() === "command" || input.view() === "model" || input.view() === "variant") {
|
|
return
|
|
}
|
|
|
|
if (promptHit(keys().clear, promptInfo(event))) {
|
|
const handled = requestExit()
|
|
if (handled) {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
})
|
|
|
|
const submitPrompt = (next: RunPrompt) => {
|
|
if (!area || area.isDestroyed) {
|
|
draft = clonePrompt(next)
|
|
}
|
|
|
|
if (visible()) {
|
|
if (mode() !== "slash" || options().length > 0) {
|
|
select()
|
|
return
|
|
}
|
|
|
|
hide()
|
|
}
|
|
|
|
if (!next.text.trim()) {
|
|
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
|
return
|
|
}
|
|
|
|
if (isExitCommand(next.text)) {
|
|
input.onExit()
|
|
return
|
|
}
|
|
|
|
const parsed = isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
|
|
if (parsed?.type === "pending") {
|
|
input.onStatus("loading commands")
|
|
return
|
|
}
|
|
|
|
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
|
|
|
|
resetDraft()
|
|
queueMicrotask(async () => {
|
|
if (await input.onSubmit(submit)) {
|
|
push(next)
|
|
return
|
|
}
|
|
|
|
restore(next)
|
|
})
|
|
}
|
|
|
|
const onSubmit = () => {
|
|
syncDraft()
|
|
submitPrompt(clonePrompt(draft))
|
|
}
|
|
|
|
const submitText = (text: string) => {
|
|
submitPrompt({ text, parts: [] })
|
|
}
|
|
|
|
onCleanup(() => {
|
|
clear()
|
|
if (area && !area.isDestroyed) {
|
|
area.off("line-info-change", scheduleRows)
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
input.width()
|
|
popup()
|
|
if (input.prompt()) {
|
|
scheduleRows()
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
query()
|
|
menu.reset()
|
|
})
|
|
|
|
createEffect(() => {
|
|
input.state().phase
|
|
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
|
|
return
|
|
}
|
|
|
|
queueMicrotask(() => {
|
|
if (!area || area.isDestroyed) {
|
|
return
|
|
}
|
|
|
|
area.focus()
|
|
})
|
|
})
|
|
|
|
createEffect(() => {
|
|
const kind = input.view()
|
|
if (kind === prev) {
|
|
return
|
|
}
|
|
|
|
if (prev === "prompt") {
|
|
syncDraft()
|
|
}
|
|
|
|
clear()
|
|
hide()
|
|
prev = kind
|
|
if (kind !== "prompt") {
|
|
return
|
|
}
|
|
|
|
queueMicrotask(() => {
|
|
restore(draft)
|
|
})
|
|
})
|
|
|
|
return {
|
|
placeholder,
|
|
bindings,
|
|
visible,
|
|
options,
|
|
selected: menu.selected,
|
|
offset: menu.offset,
|
|
rows: menu.rows,
|
|
requestExit,
|
|
onSubmit,
|
|
submitText,
|
|
onKeyDown,
|
|
onContentChange: () => {
|
|
syncDraft()
|
|
refresh()
|
|
scheduleRows()
|
|
},
|
|
replaceDraft,
|
|
bind,
|
|
}
|
|
}
|