opencode/packages/tui/src/mini/footer.prompt.tsx
2026-07-21 22:01:09 +02:00

1266 lines
33 KiB
TypeScript

// Prompt composer and its state machine for direct interactive mode.
//
// createPromptState() wires keymap command layers, history navigation, and
// `@` autocomplete for files, subagents, and project references.
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "../prompt/content"
import fuzzysort from "fuzzysort"
import path from "path"
import { pathToFileURL } from "node:url"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import {
createPromptHistory,
displayCharAt,
displaySlice,
isExitCommand,
mentionTriggerIndex,
isNewCommand,
movePromptHistory,
promptCopy,
pushPromptHistory,
} from "./prompt.shared"
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
import { Keymap } from "../context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } 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
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
action?: "skill-menu" | "editor" | "settings"
}
type PromptOption = Auto | SlashOption
type MenuMode = false | "mention" | "slash"
type PromptInput = {
directory: Accessor<string>
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
references: Accessor<RunReference[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
state: Accessor<FooterState>
view: Accessor<string>
prompt: Accessor<boolean>
width: Accessor<number>
theme: Accessor<RunFooterTheme>
mono: Accessor<boolean>
history?: Accessor<RunPrompt[]>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
onExit: () => void
onSkillMenu: () => void
onSettings: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
export type PromptState = {
placeholder: Accessor<StyledText | string>
shell: Accessor<boolean>
visible: Accessor<boolean>
options: Accessor<PromptOption[]>
selected: Accessor<number>
offset: Accessor<number>
rows: Accessor<number>
requestExit: () => boolean
onSubmit: () => void
submitText: (text: string) => void
openEditor: (input?: { value?: string }) => Promise<void>
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
replacePrompt: (prompt: RunPrompt) => void
bind: (area?: TextareaRenderable) => void
}
function clamp(rows: number): number {
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
}
function emptyPrompt(shell: boolean): RunPrompt {
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
}
function slashQuery(text: string, cursor: number) {
const head = parseSlashHead(text.slice(0, cursor))
if (!head || head.end !== cursor) {
return
}
return head.name
}
function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
const head = parseSlashHead(text)
if (!head || head.name.length === 0) {
return { type: "none" as const }
}
if (!commands) {
return { type: "pending" as const }
}
const item = commands.find((entry) => entry.name === head.name)
if (!item) {
return { type: "none" as const }
}
return {
type: "command" as const,
command: { name: head.name, arguments: head.arguments, ...(item.source ? { source: item.source } : {}) },
}
}
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
if (!command) {
return
}
const head = parseSlashHead(text)
if (!head || head.name !== command.name) {
return
}
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
// source; resolve it at submit time so routing never degrades to a plain
// command for a skill entry.
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
return {
name: command.name,
arguments: head.arguments,
...(source ? { source } : {}),
}
}
export function RunPromptBody(props: {
theme: () => RunFooterTheme
background: () => ColorInput
placeholder: () => StyledText | string
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 width="100%">
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
<textarea
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.background()}
focusedBackgroundColor={props.background()}
cursorColor={props.theme().text}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
refreshPasteLayout()
}}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
}}
/>
</box>
</box>
)
}
export function createPromptState(input: PromptInput): PromptState {
const [shell, setShell] = createSignal(false)
const placeholder = createMemo(() => {
if (shell()) {
return new StyledText([fg(input.theme().muted)('Run a command... "git status"')])
}
if (!input.state().first) {
return ""
}
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
})
let history = createPromptHistory(input.history?.())
createEffect(() => {
history = createPromptHistory(input.history?.())
})
let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | 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 setShellMode = (value: boolean) => {
setShell(value)
draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) }
}
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.id,
part: {
type: "agent",
name: item.id,
source: {
start: 0,
end: 0,
value: "",
},
},
}))
})
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: input.mono()
? monoTruncateMiddle("@" + item.name, width(), true)
: Locale.truncateMiddle("@" + item.name, width()),
value: item.name,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
type: "file",
mime: "application/x-directory",
filename: item.name,
url: pathToFileURL(item.path).href,
source: {
type: "file",
path: item.name,
text: {
start: 0,
end: 0,
value: "",
},
},
},
}))
})
const [files] = createResource(
query,
async (value) => {
if (!visible() || mode() !== "mention") {
return []
}
const next = parseFileLineRange(value)
const list = await input.findFiles(next.base)
return list.map((item): Auto => {
const url = pathToFileURL(path.resolve(input.directory(), item))
let filename = item
if (next.lineRange && !item.endsWith("/")) {
filename = `${item}#${next.lineRange.startLine}${next.lineRange.endLine ? `-${next.lineRange.endLine}` : ""}`
url.searchParams.set("start", String(next.lineRange.startLine))
if (next.lineRange.endLine !== undefined) {
url.searchParams.set("end", String(next.lineRange.endLine))
}
}
return {
kind: "mention",
display: input.mono()
? monoTruncateMiddle("@" + filename, width(), true)
: 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(), ...references()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
)
const slashOptions = createMemo<SlashOption[]>(() => {
const builtins = [
{
kind: "slash",
action: "editor" as const,
name: "editor",
display: "/editor",
description: "compose in your external editor",
} satisfies SlashOption,
{
kind: "slash",
action: "settings" as const,
name: "settings",
display: "/settings",
description: "configure Mini transcript output",
} satisfies SlashOption,
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
const hidden = new Set(builtins.map((item) => item.name))
const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
if (showSkillMenu) {
hidden.add("skills")
}
return [
...(showSkillMenu
? [
{
kind: "slash",
action: "skill-menu" as const,
name: "skills",
display: "/skills",
description: "browse available skills",
} satisfies SlashOption,
]
: []),
...(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
}
const next = stripFileLineRange(query())
if (mode() === "mention") {
return [
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...files(),
...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
]
}
return fuzzysort
.go(next, 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 hide = () => {
setMode(false)
setQuery("")
menu.reset()
}
const syncRows = () => {
if (!area || area.isDestroyed) {
return
}
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + 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 = stringWidth(value.text)) => {
draft = promptCopy(value)
setShell(value.mode === "shell")
if (!area || area.isDestroyed) {
return
}
hide()
area.setText(value.text)
restoreParts(value.parts)
area.cursorOffset = Math.min(cursor, stringWidth(area.plainText))
scheduleRows()
area.focus()
}
const resetDraft = () => {
if (area && !area.isDestroyed) {
area.setText("")
}
clearParts()
hide()
draft = emptyPrompt(shell())
if (!area || area.isDestroyed) {
return
}
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()
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
draft = shell()
? {
text: area.plainText,
parts: structuredClone(parts),
mode: "shell",
}
: {
text: area.plainText,
parts: structuredClone(parts),
...(command ? { command } : {}),
}
}
const push = (value: RunPrompt) => {
history = pushPromptHistory(history, value)
}
const move = (dir: -1 | 1, event: KeyEvent) => {
if (!area || area.isDestroyed) {
return false
}
if (history.index === null && dir === -1) {
stash = promptCopy(draft)
}
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
if (!next.apply || next.text === undefined || next.cursor === undefined) {
return false
}
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()
return true
}
const historyCommand = (dir: -1 | 1, event: KeyEvent) => {
if (move(dir, event)) return
if (!area || area.isDestroyed) return false
const endOffset = stringWidth(area.plainText)
if (dir === -1) {
if (area.cursorOffset === 0) return false
if (area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
return
}
area.moveCursorUp()
return
}
const end =
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
? area.height - 1
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
if (area.cursorOffset === endOffset) return false
if (area.visualCursor.visualRow === end) {
area.cursorOffset = endOffset
return
}
area.moveCursorDown()
}
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 openEditor = async (inputValue?: { value?: string }) => {
input.onInputClear()
syncDraft()
hide()
const current = promptCopy(draft)
try {
const content = await input.onEditorOpen({
value: inputValue?.value ?? current.text,
})
if (content === undefined) {
return
}
const normalized = normalizePromptContent(content)
restore({
text: normalized,
parts: realignEditorPromptParts(normalized, current.parts),
...(current.mode ? { mode: current.mode } : {}),
...(current.command ? { command: current.command } : {}),
})
} catch {
restore(current)
input.onStatus("failed to open editor")
}
}
const select = (item?: PromptOption) => {
const next = item ?? options()[menu.selected()]
if (!next || !area || area.isDestroyed) {
return
}
if (next.kind === "slash") {
if (next.action === "editor") {
void openEditor({
value: resolveEditorSlashValue(area.plainText),
})
return
}
if (next.action === "skill-menu") {
cancelAutocomplete()
input.onSkillMenu()
return
}
if (next.action === "settings" && !shell()) {
cancelAutocomplete()
input.onSettings()
return
}
const cursor = area.cursorOffset
const head = parseSlashHead(area.plainText)
const local = !shell() && (next.name === "new" || next.name === "exit")
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
const text = `/${next.name}${separator}`
area.cursorOffset = 0
const start = area.logicalCursor
area.cursorOffset =
shell() || !head ? cursor : local ? stringWidth(area.plainText) : stringWidth(area.plainText.slice(0, head.end))
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(text)
area.cursorOffset = stringWidth(text)
hide()
syncDraft()
if (!shell()) {
submitPrompt(promptCopy(draft))
return
}
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 + 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 baseBindingsEnabled = () => {
const current = input.view()
if (current === "command") return false
if (current === "skill") return false
if (current === "model") return false
if (current === "variant") return false
if (current === "settings") return false
if (current === "queued-menu") return false
if (current === "subagent-menu") return false
return true
}
Keymap.createLayer(() => ({
enabled: baseBindingsEnabled(),
commands: [
{
id: "prompt.clear",
title: "Clear prompt or exit",
group: "Prompt",
run() {
if (requestExit()) return
return false
},
},
],
}))
Keymap.createLayer(() => ({
enabled: input.prompt(),
commands: [
{
id: "session.interrupt",
title: "Interrupt session",
group: "Session",
run() {
if (input.onInterrupt()) return
return false
},
},
],
}))
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.editor",
title: "Open editor",
group: "Prompt",
run() {
void openEditor()
},
},
],
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.history.previous",
title: "Previous prompt history",
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(-1, event)
},
},
{
id: "prompt.history.next",
title: "Next prompt history",
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(1, event)
},
},
],
}))
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
commands: [
{
bind: "!",
title: "Shell mode",
group: "Prompt",
run() {
if (shell()) return false
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
setShellMode(true)
},
},
],
}))
Keymap.createLayer(() => ({
enabled: input.prompt() && shell() && !visible(),
commands: [
{
bind: "escape",
title: "Exit shell mode",
group: "Prompt",
run: () => setShellMode(false),
},
{
bind: "backspace",
title: "Exit shell mode",
group: "Prompt",
run() {
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
setShellMode(false)
},
},
],
}))
Keymap.createLayer(() => ({
enabled: input.prompt() && visible(),
commands: [
{
id: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
group: "Autocomplete",
run: () => menu.move(-1),
},
{
id: "prompt.autocomplete.next",
title: "Next autocomplete item",
group: "Autocomplete",
run: () => menu.move(1),
},
{
id: "prompt.autocomplete.hide",
title: "Hide autocomplete",
group: "Autocomplete",
run: cancelAutocomplete,
},
{
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
return
}
select()
},
},
{
id: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
return
}
const item = options()[menu.selected()]
if (item?.kind === "mention" && item.directory) {
expand()
return
}
select()
},
},
],
}))
const onKeyDown = (event: KeyEvent) => {
if (input.state().phase === "idle" && event.name.toLowerCase() === "escape") {
input.onInputClear()
}
}
const submitPrompt = (next: RunPrompt) => {
if (!area || area.isDestroyed) {
draft = promptCopy(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
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
}
if (!command && next.mode !== "shell" && next.text.trim().toLowerCase() === "/settings") {
resetDraft()
input.onSettings()
return
}
const parsed =
command || next.mode === "shell" || isNewCommand(next.text)
? undefined
: parseSlashCommand(next.text, input.commands())
if (parsed?.type === "pending") {
input.onStatus("loading commands")
return
}
const submit = command
? { ...next, command }
: parsed?.type === "command"
? { ...next, command: parsed.command }
: next
const shellMode = next.mode === "shell"
resetDraft()
queueMicrotask(async () => {
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
}
return
}
restore(next)
})
}
const onSubmit = () => {
syncDraft()
submitPrompt(promptCopy(draft))
}
const submitText = (text: string) => {
submitPrompt({ text, parts: [] })
}
onCleanup(() => {
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()
}
hide()
prev = kind
if (kind !== "prompt") {
return
}
queueMicrotask(() => {
restore(draft)
})
})
return {
placeholder,
shell,
visible,
options,
selected: menu.selected,
offset: menu.offset,
rows: menu.rows,
requestExit,
onSubmit,
submitText,
openEditor,
onKeyDown,
onContentChange: () => {
input.onInputClear()
syncDraft()
refresh()
scheduleRows()
},
replacePrompt: restore,
bind,
}
}