|
|
@@ -1,4 +1,14 @@
|
|
|
-import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
|
|
+import {
|
|
|
+ BoxRenderable,
|
|
|
+ RGBA,
|
|
|
+ TextareaRenderable,
|
|
|
+ MouseEvent,
|
|
|
+ PasteEvent,
|
|
|
+ decodePasteBytes,
|
|
|
+ type KeyEvent,
|
|
|
+ type Renderable,
|
|
|
+} from "@opentui/core"
|
|
|
+import type { CommandContext } from "@opentui/keymap"
|
|
|
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
|
|
import "opentui-spinner/solid"
|
|
|
import path from "path"
|
|
|
@@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event"
|
|
|
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
|
|
|
import { MessageID, PartID } from "@/session/schema"
|
|
|
import { createStore, produce, unwrap } from "solid-js/store"
|
|
|
-import { useKeybind } from "@tui/context/keybind"
|
|
|
import { usePromptHistory, type PromptInfo } from "./history"
|
|
|
import { computePromptTraits } from "./traits"
|
|
|
import { assign } from "./part"
|
|
|
import { usePromptStash } from "./stash"
|
|
|
import { DialogStash } from "../dialog-stash"
|
|
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
|
|
-import { useCommandDialog } from "../dialog-command"
|
|
|
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
|
|
import * as Editor from "@tui/util/editor"
|
|
|
import { useExit } from "../../context/exit"
|
|
|
@@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert"
|
|
|
import { useToast } from "../../ui/toast"
|
|
|
import { useKV } from "../../context/kv"
|
|
|
import { createFadeIn } from "../../util/signal"
|
|
|
-import { useTextareaKeybindings } from "../textarea-keybindings"
|
|
|
import { DialogSkill } from "../dialog-skill"
|
|
|
import {
|
|
|
confirmWorkspaceFileChanges,
|
|
|
@@ -51,7 +58,15 @@ import {
|
|
|
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
|
|
import { useArgs } from "@tui/context/args"
|
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
|
-import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
|
|
|
+import { type WorkspaceStatus } from "../workspace-label"
|
|
|
+import { useCommandPalette } from "../../context/command-palette"
|
|
|
+import {
|
|
|
+ useBindings,
|
|
|
+ useCommandShortcut,
|
|
|
+ useLeaderActive,
|
|
|
+ useOpencodeKeymap,
|
|
|
+} from "../../keymap"
|
|
|
+import { useTuiConfig } from "../../context/tui-config"
|
|
|
|
|
|
export type PromptProps = {
|
|
|
sessionID?: string
|
|
|
@@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
|
|
export function Prompt(props: PromptProps) {
|
|
|
let input: TextareaRenderable
|
|
|
let anchor: BoxRenderable
|
|
|
- let autocomplete: AutocompleteRef
|
|
|
+ const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
|
|
|
|
|
- const keybind = useKeybind()
|
|
|
+ const leader = useLeaderActive()
|
|
|
const local = useLocal()
|
|
|
const args = useArgs()
|
|
|
const sdk = useSDK()
|
|
|
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
|
|
|
const route = useRoute()
|
|
|
const project = useProject()
|
|
|
const sync = useSync()
|
|
|
+ const tuiConfig = useTuiConfig()
|
|
|
+ const keymapConfig = tuiConfig.keymap
|
|
|
const dialog = useDialog()
|
|
|
const toast = useToast()
|
|
|
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
|
|
const history = usePromptHistory()
|
|
|
const stash = usePromptStash()
|
|
|
- const command = useCommandDialog()
|
|
|
+ const command = useCommandPalette()
|
|
|
+ const keymap = useOpencodeKeymap()
|
|
|
+ const agentShortcut = useCommandShortcut("agent.cycle")
|
|
|
+ const paletteShortcut = useCommandShortcut("command.palette.show")
|
|
|
const renderer = useRenderer()
|
|
|
const dimensions = useTerminalDimensions()
|
|
|
const { theme, syntax } = useTheme()
|
|
|
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
|
|
|
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
|
|
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
|
|
const [warpNotice, setWarpNotice] = createSignal<string>()
|
|
|
+ const [cursorVersion, setCursorVersion] = createSignal(0)
|
|
|
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
|
|
const hasRightContent = createMemo(() => Boolean(props.right))
|
|
|
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
|
|
|
@@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) {
|
|
|
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
|
|
|
editor.clearSelection()
|
|
|
}
|
|
|
-
|
|
|
- const textareaKeybindings = useTextareaKeybindings()
|
|
|
-
|
|
|
const fileStyleId = syntax().getStyleId("extmark.file")!
|
|
|
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
|
|
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
|
|
@@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- command.register(() => {
|
|
|
- return [
|
|
|
+ const promptCommands = createMemo(() =>
|
|
|
+ [
|
|
|
{
|
|
|
title: "Clear prompt",
|
|
|
- value: "prompt.clear",
|
|
|
+ name: "prompt.clear",
|
|
|
category: "Prompt",
|
|
|
hidden: true,
|
|
|
- onSelect: (dialog) => {
|
|
|
- input.extmarks.clear()
|
|
|
+ run: () => {
|
|
|
input.clear()
|
|
|
+ input.extmarks.clear()
|
|
|
+ setStore("prompt", {
|
|
|
+ input: "",
|
|
|
+ parts: [],
|
|
|
+ })
|
|
|
+ setStore("extmarkToPartIndex", new Map())
|
|
|
dialog.clear()
|
|
|
},
|
|
|
},
|
|
|
{
|
|
|
title: "Submit prompt",
|
|
|
- value: "prompt.submit",
|
|
|
- keybind: "input_submit",
|
|
|
+ name: "prompt.submit",
|
|
|
category: "Prompt",
|
|
|
hidden: true,
|
|
|
- onSelect: async (dialog) => {
|
|
|
+ run: async () => {
|
|
|
if (!input.focused) return
|
|
|
const handled = await submit()
|
|
|
if (!handled) return
|
|
|
@@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) {
|
|
|
},
|
|
|
{
|
|
|
title: "Remove editor context",
|
|
|
- value: "prompt.editor_context.clear",
|
|
|
+ name: "prompt.editor_context.clear",
|
|
|
category: "Prompt",
|
|
|
enabled: Boolean(editorContext()),
|
|
|
- onSelect: (dialog) => {
|
|
|
+ run: () => {
|
|
|
dismissEditorContext()
|
|
|
dialog.clear()
|
|
|
},
|
|
|
},
|
|
|
{
|
|
|
title: "Paste",
|
|
|
- value: "prompt.paste",
|
|
|
- keybind: "input_paste",
|
|
|
+ name: "prompt.paste",
|
|
|
category: "Prompt",
|
|
|
hidden: true,
|
|
|
- onSelect: async () => {
|
|
|
+ run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
|
|
|
+ ctx.event.preventDefault()
|
|
|
+ ctx.event.stopPropagation()
|
|
|
const content = await Clipboard.read()
|
|
|
if (content?.mime.startsWith("image/")) {
|
|
|
await pasteAttachment({
|
|
|
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
|
|
|
mime: content.mime,
|
|
|
content: content.data,
|
|
|
})
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (content?.mime === "text/plain") {
|
|
|
+ await pasteInputText(content.data)
|
|
|
}
|
|
|
},
|
|
|
},
|
|
|
{
|
|
|
title: "Interrupt session",
|
|
|
- value: "session.interrupt",
|
|
|
- keybind: "session_interrupt",
|
|
|
+ name: "session.interrupt",
|
|
|
category: "Session",
|
|
|
hidden: true,
|
|
|
enabled: status().type !== "idle",
|
|
|
- onSelect: (dialog) => {
|
|
|
- if (autocomplete.visible) return
|
|
|
+ run: () => {
|
|
|
+ if (auto()?.visible) return
|
|
|
if (!input.focused) return
|
|
|
// TODO: this should be its own command
|
|
|
if (store.mode === "shell") {
|
|
|
@@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) {
|
|
|
{
|
|
|
title: "Open editor",
|
|
|
category: "Session",
|
|
|
- keybind: "editor_open",
|
|
|
- value: "prompt.editor",
|
|
|
- slash: {
|
|
|
- name: "editor",
|
|
|
- },
|
|
|
- onSelect: async (dialog) => {
|
|
|
+ name: "prompt.editor",
|
|
|
+ slashName: "editor",
|
|
|
+ run: async () => {
|
|
|
dialog.clear()
|
|
|
|
|
|
// replace summarized text parts with the actual text
|
|
|
@@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) {
|
|
|
},
|
|
|
{
|
|
|
title: "Skills",
|
|
|
- value: "prompt.skills",
|
|
|
+ name: "prompt.skills",
|
|
|
category: "Prompt",
|
|
|
- slash: {
|
|
|
- name: "skills",
|
|
|
- },
|
|
|
- onSelect: () => {
|
|
|
+ slashName: "skills",
|
|
|
+ run: () => {
|
|
|
dialog.replace(() => (
|
|
|
<DialogSkill
|
|
|
onSelect={(skill) => {
|
|
|
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
|
|
|
},
|
|
|
{
|
|
|
title: "Warp",
|
|
|
- description: "Change the workspace for the session",
|
|
|
- value: "workspace.set",
|
|
|
+ desc: "Change the workspace for the session",
|
|
|
+ name: "workspace.set",
|
|
|
category: "Session",
|
|
|
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
|
|
- slash: {
|
|
|
- name: "warp",
|
|
|
- },
|
|
|
- onSelect: (dialog) => {
|
|
|
+ slashName: "warp",
|
|
|
+ run: () => {
|
|
|
void openWorkspaceSelect({
|
|
|
dialog,
|
|
|
sdk,
|
|
|
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
|
|
|
})
|
|
|
},
|
|
|
},
|
|
|
- ]
|
|
|
- })
|
|
|
+ ].map((entry) => ({
|
|
|
+ namespace: "palette",
|
|
|
+ ...entry,
|
|
|
+ })),
|
|
|
+ )
|
|
|
+
|
|
|
+ useBindings(() => ({
|
|
|
+ commands: promptCommands(),
|
|
|
+ }))
|
|
|
+
|
|
|
+ useBindings(() => ({
|
|
|
+ enabled: command.matcher,
|
|
|
+ bindings: keymapConfig.pick("prompt", [
|
|
|
+ "prompt.submit",
|
|
|
+ "prompt.editor",
|
|
|
+ "prompt.editor_context.clear",
|
|
|
+ "prompt.stash",
|
|
|
+ "prompt.stash.pop",
|
|
|
+ "prompt.stash.list",
|
|
|
+ "session.interrupt",
|
|
|
+ "workspace.set",
|
|
|
+ ]),
|
|
|
+ }))
|
|
|
|
|
|
const ref: PromptRef = {
|
|
|
get focused() {
|
|
|
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
|
|
|
if (store.prompt.input) {
|
|
|
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
|
|
}
|
|
|
+ setInputTarget(undefined)
|
|
|
props.ref?.(undefined)
|
|
|
})
|
|
|
|
|
|
@@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) {
|
|
|
|
|
|
createEffect(() => {
|
|
|
if (!input || input.isDestroyed) return
|
|
|
- input.traits = computePromptTraits({
|
|
|
- mode: store.mode,
|
|
|
- disabled: !!props.disabled,
|
|
|
- autocompleteVisible: !!auto()?.visible,
|
|
|
- })
|
|
|
+ input.traits = {
|
|
|
+ ...input.traits,
|
|
|
+ ...computePromptTraits({
|
|
|
+ mode: store.mode,
|
|
|
+ disabled: !!props.disabled,
|
|
|
+ autocompleteVisible: !!auto()?.visible,
|
|
|
+ }),
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
|
|
@@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- command.register(() => [
|
|
|
- {
|
|
|
- title: "Stash prompt",
|
|
|
- value: "prompt.stash",
|
|
|
- category: "Prompt",
|
|
|
- enabled: !!store.prompt.input,
|
|
|
- onSelect: (dialog) => {
|
|
|
- if (!store.prompt.input) return
|
|
|
- stash.push({
|
|
|
- input: store.prompt.input,
|
|
|
- parts: store.prompt.parts,
|
|
|
- })
|
|
|
- input.extmarks.clear()
|
|
|
- input.clear()
|
|
|
- setStore("prompt", { input: "", parts: [] })
|
|
|
- setStore("extmarkToPartIndex", new Map())
|
|
|
- dialog.clear()
|
|
|
+ const stashCommands = createMemo(() =>
|
|
|
+ [
|
|
|
+ {
|
|
|
+ title: "Stash prompt",
|
|
|
+ name: "prompt.stash",
|
|
|
+ category: "Prompt",
|
|
|
+ enabled: !!store.prompt.input,
|
|
|
+ run: () => {
|
|
|
+ if (!store.prompt.input) return
|
|
|
+ stash.push({
|
|
|
+ input: store.prompt.input,
|
|
|
+ parts: store.prompt.parts,
|
|
|
+ })
|
|
|
+ input.extmarks.clear()
|
|
|
+ input.clear()
|
|
|
+ setStore("prompt", { input: "", parts: [] })
|
|
|
+ setStore("extmarkToPartIndex", new Map())
|
|
|
+ dialog.clear()
|
|
|
+ },
|
|
|
},
|
|
|
- },
|
|
|
- {
|
|
|
- title: "Stash pop",
|
|
|
- value: "prompt.stash.pop",
|
|
|
- category: "Prompt",
|
|
|
- enabled: stash.list().length > 0,
|
|
|
- onSelect: (dialog) => {
|
|
|
- const entry = stash.pop()
|
|
|
- if (entry) {
|
|
|
- input.setText(entry.input)
|
|
|
- setStore("prompt", { input: entry.input, parts: entry.parts })
|
|
|
- restoreExtmarksFromParts(entry.parts)
|
|
|
- input.gotoBufferEnd()
|
|
|
- }
|
|
|
- dialog.clear()
|
|
|
+ {
|
|
|
+ title: "Stash pop",
|
|
|
+ name: "prompt.stash.pop",
|
|
|
+ category: "Prompt",
|
|
|
+ enabled: stash.list().length > 0,
|
|
|
+ run: () => {
|
|
|
+ const entry = stash.pop()
|
|
|
+ if (entry) {
|
|
|
+ input.setText(entry.input)
|
|
|
+ setStore("prompt", { input: entry.input, parts: entry.parts })
|
|
|
+ restoreExtmarksFromParts(entry.parts)
|
|
|
+ input.gotoBufferEnd()
|
|
|
+ }
|
|
|
+ dialog.clear()
|
|
|
+ },
|
|
|
},
|
|
|
- },
|
|
|
- {
|
|
|
- title: "Stash list",
|
|
|
- value: "prompt.stash.list",
|
|
|
- category: "Prompt",
|
|
|
- enabled: stash.list().length > 0,
|
|
|
- onSelect: (dialog) => {
|
|
|
- dialog.replace(() => (
|
|
|
- <DialogStash
|
|
|
- onSelect={(entry) => {
|
|
|
- input.setText(entry.input)
|
|
|
- setStore("prompt", { input: entry.input, parts: entry.parts })
|
|
|
- restoreExtmarksFromParts(entry.parts)
|
|
|
- input.gotoBufferEnd()
|
|
|
- }}
|
|
|
- />
|
|
|
- ))
|
|
|
+ {
|
|
|
+ title: "Stash list",
|
|
|
+ name: "prompt.stash.list",
|
|
|
+ category: "Prompt",
|
|
|
+ enabled: stash.list().length > 0,
|
|
|
+ run: () => {
|
|
|
+ dialog.replace(() => (
|
|
|
+ <DialogStash
|
|
|
+ onSelect={(entry) => {
|
|
|
+ input.setText(entry.input)
|
|
|
+ setStore("prompt", { input: entry.input, parts: entry.parts })
|
|
|
+ restoreExtmarksFromParts(entry.parts)
|
|
|
+ input.gotoBufferEnd()
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ },
|
|
|
},
|
|
|
- },
|
|
|
- ])
|
|
|
+ ].map((entry) => ({
|
|
|
+ namespace: "palette",
|
|
|
+ ...entry,
|
|
|
+ })),
|
|
|
+ )
|
|
|
+
|
|
|
+ useBindings(() => ({
|
|
|
+ commands: stashCommands(),
|
|
|
+ }))
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: inputTarget() !== undefined && !props.disabled,
|
|
|
+ bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
|
|
|
+ bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: (() => {
|
|
|
+ cursorVersion()
|
|
|
+ return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
|
|
|
+ })(),
|
|
|
+ bindings: [
|
|
|
+ {
|
|
|
+ key: "!",
|
|
|
+ cmd: () => {
|
|
|
+ setStore("placeholder", randomIndex(shell().length))
|
|
|
+ setStore("mode", "shell")
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: inputTarget() !== undefined && store.mode === "shell",
|
|
|
+ bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: (() => {
|
|
|
+ cursorVersion()
|
|
|
+ return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
|
|
+ })(),
|
|
|
+ bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: (() => {
|
|
|
+ cursorVersion()
|
|
|
+ return (
|
|
|
+ inputTarget() !== undefined &&
|
|
|
+ !props.disabled &&
|
|
|
+ !auto()?.visible &&
|
|
|
+ input !== undefined &&
|
|
|
+ (input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
|
|
|
+ )
|
|
|
+ })(),
|
|
|
+ commands: [
|
|
|
+ {
|
|
|
+ name: "prompt.history.previous",
|
|
|
+ run() {
|
|
|
+ if (input.cursorOffset !== 0) {
|
|
|
+ input.cursorOffset = 0
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const item = history.move(-1, input.plainText)
|
|
|
+ if (!item) return
|
|
|
+ input.setText(item.input)
|
|
|
+ setStore("prompt", item)
|
|
|
+ setStore("mode", item.mode ?? "normal")
|
|
|
+ restoreExtmarksFromParts(item.parts)
|
|
|
+ input.cursorOffset = 0
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ useBindings(() => {
|
|
|
+ return {
|
|
|
+ target: inputTarget,
|
|
|
+ enabled: (() => {
|
|
|
+ cursorVersion()
|
|
|
+ return (
|
|
|
+ inputTarget() !== undefined &&
|
|
|
+ !props.disabled &&
|
|
|
+ !auto()?.visible &&
|
|
|
+ input !== undefined &&
|
|
|
+ (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
|
|
|
+ )
|
|
|
+ })(),
|
|
|
+ commands: [
|
|
|
+ {
|
|
|
+ name: "prompt.history.next",
|
|
|
+ run() {
|
|
|
+ if (input.cursorOffset !== input.plainText.length) {
|
|
|
+ input.cursorOffset = input.plainText.length
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const item = history.move(1, input.plainText)
|
|
|
+ if (!item) return
|
|
|
+ input.setText(item.input)
|
|
|
+ setStore("prompt", item)
|
|
|
+ setStore("mode", item.mode ?? "normal")
|
|
|
+ restoreExtmarksFromParts(item.parts)
|
|
|
+ input.cursorOffset = input.plainText.length
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
async function submit() {
|
|
|
setWarpNotice(undefined)
|
|
|
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
|
|
|
}
|
|
|
if (props.disabled) return false
|
|
|
if (workspaceCreating()) return false
|
|
|
- if (autocomplete?.visible) return false
|
|
|
+ if (auto()?.visible) return false
|
|
|
if (!store.prompt.input) return false
|
|
|
const agent = local.agent.current()
|
|
|
if (!agent) return false
|
|
|
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ async function pasteInputText(text: string) {
|
|
|
+ const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
|
|
+ const pastedContent = normalizedText.trim()
|
|
|
+ const filepath = iife(() => {
|
|
|
+ const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
|
|
+ if (raw.startsWith("file://")) {
|
|
|
+ try {
|
|
|
+ return fileURLToPath(raw)
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ if (process.platform === "win32") return raw
|
|
|
+ return raw.replace(/\\(.)/g, "$1")
|
|
|
+ })
|
|
|
+ const isUrl = /^(https?):\/\//.test(filepath)
|
|
|
+ if (!isUrl) {
|
|
|
+ try {
|
|
|
+ const mime = await Filesystem.mimeType(filepath)
|
|
|
+ const filename = path.basename(filepath)
|
|
|
+ if (mime === "image/svg+xml") {
|
|
|
+ const content = await Filesystem.readText(filepath).catch(() => {})
|
|
|
+ if (content) {
|
|
|
+ pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (mime.startsWith("image/") || mime === "application/pdf") {
|
|
|
+ const content = await Filesystem.readArrayBuffer(filepath)
|
|
|
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
|
|
|
+ .catch(() => {})
|
|
|
+ if (content) {
|
|
|
+ await pasteAttachment({
|
|
|
+ filename,
|
|
|
+ filepath,
|
|
|
+ mime,
|
|
|
+ content,
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+
|
|
|
+ const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
|
|
+ if (
|
|
|
+ (lineCount >= 3 || pastedContent.length > 150) &&
|
|
|
+ kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
|
|
+ ) {
|
|
|
+ pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ input.insertText(normalizedText)
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ if (!input || input.isDestroyed) return
|
|
|
+ input.getLayoutNode().markDirty()
|
|
|
+ renderer.requestRender()
|
|
|
+ }, 0)
|
|
|
+ }
|
|
|
+
|
|
|
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
|
|
|
const currentOffset = input.visualCursor.offset
|
|
|
const extmarkStart = currentOffset
|
|
|
@@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) {
|
|
|
}
|
|
|
|
|
|
const highlight = createMemo(() => {
|
|
|
- if (keybind.leader) return theme.border
|
|
|
+ if (leader()) return theme.border
|
|
|
if (store.mode === "shell") return theme.primary
|
|
|
const agent = local.agent.current()
|
|
|
if (!agent) return theme.border
|
|
|
@@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) {
|
|
|
|
|
|
return (
|
|
|
<>
|
|
|
- <Autocomplete
|
|
|
- sessionID={props.sessionID}
|
|
|
- ref={(r) => {
|
|
|
- autocomplete = r
|
|
|
- setAuto(() => r)
|
|
|
- }}
|
|
|
- anchor={() => anchor}
|
|
|
- input={() => input}
|
|
|
- setPrompt={(cb) => {
|
|
|
- setStore("prompt", produce(cb))
|
|
|
- }}
|
|
|
- setExtmark={(partIndex, extmarkId) => {
|
|
|
- setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
|
|
- const newMap = new Map(map)
|
|
|
- newMap.set(extmarkId, partIndex)
|
|
|
- return newMap
|
|
|
- })
|
|
|
- }}
|
|
|
- value={store.prompt.input}
|
|
|
- fileStyleId={fileStyleId}
|
|
|
- agentStyleId={agentStyleId}
|
|
|
- promptPartTypeId={() => promptPartTypeId}
|
|
|
- />
|
|
|
- <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
|
|
+ <box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
|
|
|
<box
|
|
|
border={["left"]}
|
|
|
borderColor={borderHighlight()}
|
|
|
@@ -1249,94 +1465,23 @@ export function Prompt(props: PromptProps) {
|
|
|
<textarea
|
|
|
placeholder={placeholderText()}
|
|
|
placeholderColor={theme.textMuted}
|
|
|
- textColor={keybind.leader ? theme.textMuted : theme.text}
|
|
|
- focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
|
|
|
+ textColor={leader() ? theme.textMuted : theme.text}
|
|
|
+ focusedTextColor={leader() ? theme.textMuted : theme.text}
|
|
|
minHeight={1}
|
|
|
maxHeight={6}
|
|
|
onContentChange={() => {
|
|
|
const value = input.plainText
|
|
|
setStore("prompt", "input", value)
|
|
|
- autocomplete.onInput(value)
|
|
|
+ auto()?.onInput(value)
|
|
|
syncExtmarksWithPromptParts()
|
|
|
+ setCursorVersion((value) => value + 1)
|
|
|
}}
|
|
|
- keyBindings={textareaKeybindings()}
|
|
|
- onKeyDown={async (e) => {
|
|
|
+ onCursorChange={() => setCursorVersion((value) => value + 1)}
|
|
|
+ onKeyDown={(e: { preventDefault(): void }) => {
|
|
|
if (props.disabled) {
|
|
|
e.preventDefault()
|
|
|
return
|
|
|
}
|
|
|
- // Check clipboard for images before terminal-handled paste runs.
|
|
|
- // This helps terminals that forward Ctrl+V to the app; Windows
|
|
|
- // Terminal 1.25+ usually handles Ctrl+V before this path.
|
|
|
- if (keybind.match("input_paste", e)) {
|
|
|
- const content = await Clipboard.read()
|
|
|
- if (content?.mime.startsWith("image/")) {
|
|
|
- e.preventDefault()
|
|
|
- await pasteAttachment({
|
|
|
- filename: "clipboard",
|
|
|
- mime: content.mime,
|
|
|
- content: content.data,
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
- // If no image, let the default paste behavior continue
|
|
|
- }
|
|
|
- if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
|
|
- input.clear()
|
|
|
- input.extmarks.clear()
|
|
|
- setStore("prompt", {
|
|
|
- input: "",
|
|
|
- parts: [],
|
|
|
- })
|
|
|
- setStore("extmarkToPartIndex", new Map())
|
|
|
- return
|
|
|
- }
|
|
|
- if (keybind.match("app_exit", e)) {
|
|
|
- if (store.prompt.input === "") {
|
|
|
- await exit()
|
|
|
- // Don't preventDefault - let textarea potentially handle the event
|
|
|
- e.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- if (e.name === "!" && input.visualCursor.offset === 0) {
|
|
|
- setStore("placeholder", randomIndex(shell().length))
|
|
|
- setStore("mode", "shell")
|
|
|
- e.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
- if (store.mode === "shell") {
|
|
|
- if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
|
|
|
- setStore("mode", "normal")
|
|
|
- e.preventDefault()
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- if (store.mode === "normal") autocomplete.onKeyDown(e)
|
|
|
- if (!autocomplete.visible) {
|
|
|
- if (
|
|
|
- (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
|
|
- (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
|
|
|
- ) {
|
|
|
- const direction = keybind.match("history_previous", e) ? -1 : 1
|
|
|
- const item = history.move(direction, input.plainText)
|
|
|
-
|
|
|
- if (item) {
|
|
|
- input.setText(item.input)
|
|
|
- setStore("prompt", item)
|
|
|
- setStore("mode", item.mode ?? "normal")
|
|
|
- restoreExtmarksFromParts(item.parts)
|
|
|
- e.preventDefault()
|
|
|
- if (direction === -1) input.cursorOffset = 0
|
|
|
- if (direction === 1) input.cursorOffset = input.plainText.length
|
|
|
- }
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
|
|
- if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
|
|
|
- input.cursorOffset = input.plainText.length
|
|
|
- }
|
|
|
}}
|
|
|
onSubmit={() => {
|
|
|
// IME: double-defer so the last composed character (e.g. Korean
|
|
|
@@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) {
|
|
|
// Windows Terminal <1.25 can surface image-only clipboard as an
|
|
|
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
|
|
if (!pastedContent) {
|
|
|
- command.trigger("prompt.paste")
|
|
|
+ keymap.dispatchCommand("prompt.paste")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
|
|
|
// default paste unless we suppress it first and handle insertion ourselves.
|
|
|
event.preventDefault()
|
|
|
|
|
|
- const filepath = iife(() => {
|
|
|
- const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
|
|
- if (raw.startsWith("file://")) {
|
|
|
- try {
|
|
|
- return fileURLToPath(raw)
|
|
|
- } catch {}
|
|
|
- }
|
|
|
- if (process.platform === "win32") return raw
|
|
|
- return raw.replace(/\\(.)/g, "$1")
|
|
|
- })
|
|
|
- const isUrl = /^(https?):\/\//.test(filepath)
|
|
|
- if (!isUrl) {
|
|
|
- try {
|
|
|
- const mime = await Filesystem.mimeType(filepath)
|
|
|
- const filename = path.basename(filepath)
|
|
|
- // Handle SVG as raw text content, not as base64 image
|
|
|
- if (mime === "image/svg+xml") {
|
|
|
- const content = await Filesystem.readText(filepath).catch(() => {})
|
|
|
- if (content) {
|
|
|
- pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- if (mime.startsWith("image/") || mime === "application/pdf") {
|
|
|
- const content = await Filesystem.readArrayBuffer(filepath)
|
|
|
- .then((buffer) => Buffer.from(buffer).toString("base64"))
|
|
|
- .catch(() => {})
|
|
|
- if (content) {
|
|
|
- await pasteAttachment({
|
|
|
- filename,
|
|
|
- filepath,
|
|
|
- mime,
|
|
|
- content,
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- } catch {}
|
|
|
- }
|
|
|
-
|
|
|
- const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
|
|
- if (
|
|
|
- (lineCount >= 3 || pastedContent.length > 150) &&
|
|
|
- kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
|
|
- ) {
|
|
|
- pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- input.insertText(normalizedText)
|
|
|
-
|
|
|
- // Force layout update and render for the pasted content
|
|
|
- setTimeout(() => {
|
|
|
- // setTimeout is a workaround and needs to be addressed properly
|
|
|
- if (!input || input.isDestroyed) return
|
|
|
- input.getLayoutNode().markDirty()
|
|
|
- renderer.requestRender()
|
|
|
- }, 0)
|
|
|
+ await pasteInputText(normalizedText)
|
|
|
}}
|
|
|
ref={(r: TextareaRenderable) => {
|
|
|
input = r
|
|
|
+ setInputTarget(r)
|
|
|
if (promptPartTypeId === 0) {
|
|
|
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
|
|
}
|
|
|
@@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) {
|
|
|
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
|
|
|
<text
|
|
|
flexShrink={0}
|
|
|
- fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
|
|
|
+ fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
|
|
|
>
|
|
|
{local.model.parsed().model}
|
|
|
</text>
|
|
|
@@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) {
|
|
|
</Match>
|
|
|
<Match when={true}>
|
|
|
<text fg={theme.text}>
|
|
|
- {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
|
|
+ {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
|
|
</text>
|
|
|
</Match>
|
|
|
</Switch>
|
|
|
<text fg={theme.text}>
|
|
|
- {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
|
|
+ {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
|
|
</text>
|
|
|
</Match>
|
|
|
<Match when={store.mode === "shell"}>
|
|
|
@@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) {
|
|
|
</Show>
|
|
|
</box>
|
|
|
</box>
|
|
|
+ <Autocomplete
|
|
|
+ sessionID={props.sessionID}
|
|
|
+ ref={(r) => {
|
|
|
+ setAuto(() => r)
|
|
|
+ }}
|
|
|
+ anchor={() => anchor}
|
|
|
+ input={() => input}
|
|
|
+ setPrompt={(cb) => {
|
|
|
+ setStore("prompt", produce(cb))
|
|
|
+ }}
|
|
|
+ setExtmark={(partIndex, extmarkId) => {
|
|
|
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
|
|
+ const newMap = new Map(map)
|
|
|
+ newMap.set(extmarkId, partIndex)
|
|
|
+ return newMap
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ value={store.prompt.input}
|
|
|
+ fileStyleId={fileStyleId}
|
|
|
+ agentStyleId={agentStyleId}
|
|
|
+ promptPartTypeId={() => promptPartTypeId}
|
|
|
+ />
|
|
|
</>
|
|
|
)
|
|
|
}
|