Opentui textarea (#3399)

Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Dax 2025-10-27 15:42:00 -04:00 committed by GitHub
parent 96498cf43c
commit af0f40e3cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 339 additions and 124 deletions

View file

@ -185,8 +185,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.30",
"@opentui/solid": "0.1.30",
"@opentui/core": "0.0.0-20251027-327d7e76",
"@opentui/solid": "0.0.0-20251027-327d7e76",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@ -954,21 +954,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.30", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.30", "@opentui/core-darwin-x64": "0.1.30", "@opentui/core-linux-arm64": "0.1.30", "@opentui/core-linux-x64": "0.1.30", "@opentui/core-win32-arm64": "0.1.30", "@opentui/core-win32-x64": "0.1.30", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-DoPF3E//UaISDfp7jYhdU4KbOe7BVm9KqCV+TPMVo2lch8UfvtN2nCnHqtg54DCzxYuTbge9NDrapdt3jrT2oA=="],
"@opentui/core": ["@opentui/core@0.0.0-20251027-327d7e76", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251027-327d7e76", "@opentui/core-darwin-x64": "0.0.0-20251027-327d7e76", "@opentui/core-linux-arm64": "0.0.0-20251027-327d7e76", "@opentui/core-linux-x64": "0.0.0-20251027-327d7e76", "@opentui/core-win32-arm64": "0.0.0-20251027-327d7e76", "@opentui/core-win32-x64": "0.0.0-20251027-327d7e76", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-9EF4TkLR4szqNmWDGYZrzti48aQ3WOaXbTKOxcAEIBNienTlvr7baNyUjwNCHsbMxsQrAIYIY7gKXjebsChxkg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.30", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFFBsVsrByzA/9LpJZ7uV2QWH44Hq/96eWO8PlPhMdlnvpqz5e0PYU3fQJUuDGptIvb9GZzIt5Og2gYar5bUIw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251027-327d7e76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iOY4266FFXc1c2NqYBg5ED1YkfT5z7yVCrlLsqd9EIpWv72NIN3b9HLbY77jzsjDYqtFfOx2x96FyS8As+La3w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.30", "", { "os": "darwin", "cpu": "x64" }, "sha512-f8MNPqwfG9qUttxcrQ3VsM+rrWvO9uHGncTJbKYEKDVaQzBuKpkEZqMSA2JV3gspjL22DXSG4Q5LE6NzvmITgQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251027-327d7e76", "", { "os": "darwin", "cpu": "x64" }, "sha512-fM441iHIG9TRRBv9y/bNeG4ZymhKi4FTtdvaPDRJ9rqljTGL/NwiP57kMiaO8Gv2y9dqzUmxDyu0+QHqexl6BQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.30", "", { "os": "linux", "cpu": "arm64" }, "sha512-6qBVjUU9XJrgePvFtCGmEIU+iRfyyydvbts+i7nKjJgmOFVZLnllP6XqA3s+lAjEQrzp+VM7beDV5MIOMjHHZg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251027-327d7e76", "", { "os": "linux", "cpu": "arm64" }, "sha512-fDnx9X6+UcPj5p3LnJV2RAKwEw82pabZwc6eIPsUNIWZj861/OnDOmxwDEMLJw4G+j5IBnk5oeHoNu6YeG0V1Q=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.30", "", { "os": "linux", "cpu": "x64" }, "sha512-K51WGZp7VT5aB8nbzZBt94YavMnI1yFe4h4n/e4TxZ4Ph3BACNmNHizEDfn05efcmXmMdgGOjazc5sNA2LrOOQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251027-327d7e76", "", { "os": "linux", "cpu": "x64" }, "sha512-oUNT2QuL+b3e7g22KsUGWsi1lktiorLg1xlgEB+HWpn7dtK9xX+6gc1dmXp2qtgJvXhlOWSCCBftSmVvLFn4eg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.30", "", { "os": "win32", "cpu": "arm64" }, "sha512-NAkCu0VSHh5oj9wldjImqli7g4kvkA/3HP9PyAWB6zBg+QcXCl7Yi1WPEFwAU7XM1zEUBwhrj0sHcuMomq4Y8A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251027-327d7e76", "", { "os": "win32", "cpu": "arm64" }, "sha512-xKFfTRO/JYTnQD+Erd9tOaEPvJPwtOVDQNQ5VSRPXdyB2YAruiZ57nnryJJ6WQjIXPy+ND4X77mylxOUFTk5Wg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.30", "", { "os": "win32", "cpu": "x64" }, "sha512-eNIQaLm+Muluma1xXa2SxHGidjknH+iVmPPbQsV7xwJlp3a318BjTr/5/lxKBz1EGzhTaiwBqwcgTSg+Q31L5w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251027-327d7e76", "", { "os": "win32", "cpu": "x64" }, "sha512-Ymctt059k8ZVXnLCcqZC+h1f81W0KgKPGzS3Ve/+Br/v7fwsE0mWCNuinbxrFgIHMs+6947u88rp/tyhwMQBJQ=="],
"@opentui/solid": ["@opentui/solid@0.1.30", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.30", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-7JAHQH8OLgJCjH2IBmdO3ggd7WHavEdlywhVzjoVvQYonTYcJjcJ2F6MJ1Qmqqm6y2+IYv3KIYMQm6HLxX5TxQ=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20251027-327d7e76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251027-327d7e76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-/eTp1WbjRQs2tVqXPgAVKlhMmzJP60SZ+ZJ8S4cqdQ12g++WrFWCMb8OcueLs/rgKHr07WORo7Aw9/a9I1vV/w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View file

@ -50,8 +50,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.30",
"@opentui/solid": "0.1.30",
"@opentui/core": "0.0.0-20251027-327d7e76",
"@opentui/solid": "0.0.0-20251027-327d7e76",
"@parcel/watcher": "2.5.1",
"@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:",

View file

@ -1,4 +1,4 @@
import type { ParsedKey, BoxRenderable, InputRenderable } from "@opentui/core"
import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
@ -12,7 +12,7 @@ import type { PromptInfo } from "./history"
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: ParsedKey) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/"
}
@ -27,9 +27,13 @@ export function Autocomplete(props: {
value: string
sessionID?: string
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (partIndex: number, extmarkId: number) => void
anchor: () => BoxRenderable
input: () => InputRenderable
input: () => TextareaRenderable
ref: (ref: AutocompleteRef) => void
fileStyleId: number
agentStyleId: number
promptPartTypeId: () => number
}) {
const sdk = useSDK()
const sync = useSync()
@ -46,6 +50,49 @@ export function Autocomplete(props: {
return props.value.substring(store.index + 1).split(" ")[0]
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const append = "@" + text + " "
const input = props.input()
const currentCursorOffset = input.visualCursor.offset
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText(append)
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + virtualText.length
const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId,
typeId: props.promptPartTypeId(),
})
props.setPrompt((draft) => {
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
part.source.text.value = virtualText
} else if (part.type === "agent" && part.source) {
part.source.start = extmarkStart
part.source.end = extmarkEnd
part.source.value = virtualText
}
const partIndex = draft.parts.length
draft.parts.push(part)
props.setExtmark(partIndex, extmarkId)
})
}
const [files] = createResource(
() => [filter()],
async () => {
@ -68,7 +115,7 @@ export function Autocomplete(props: {
(item): AutocompleteOption => ({
display: item,
onSelect: () => {
const part: PromptInfo["parts"][number] = {
insertPart(item, {
type: "file",
mime: "text/plain",
filename: item,
@ -76,18 +123,12 @@ export function Autocomplete(props: {
source: {
type: "file",
text: {
start: store.index,
end: store.index + item.length + 1,
value: "@" + item,
start: 0,
end: 0,
value: "",
},
path: item,
},
}
props.setPrompt((draft) => {
const append = "@" + item + " "
if (store.index === 0) draft.input = append
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
draft.parts.push(part)
})
},
}),
@ -111,18 +152,14 @@ export function Autocomplete(props: {
(agent): AutocompleteOption => ({
display: "@" + agent.name,
onSelect: () => {
props.setPrompt((draft) => {
const append = "@" + agent.name + " "
draft.input = append
draft.parts.push({
type: "agent",
source: {
start: store.index,
end: store.index + agent.name.length + 1,
value: "@" + agent.name,
},
name: agent.name,
})
insertPart(agent.name, {
type: "agent",
name: agent.name,
source: {
start: 0,
end: 0,
value: "",
},
})
},
}),
@ -138,8 +175,11 @@ export function Autocomplete(props: {
display: "/" + command.name,
description: command.description,
onSelect: () => {
props.input().value = "/" + command.name + " "
props.input().cursorPosition = props.input().value.length
const newText = "/" + command.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
})
}
@ -234,13 +274,13 @@ export function Autocomplete(props: {
const selected = options()[store.selected]
if (!selected) return
selected.onSelect?.()
setTimeout(() => hide(), 0)
hide()
}
function show(mode: "@" | "/") {
setStore({
visible: mode,
index: props.input().cursorPosition,
index: props.input().visualCursor.offset,
position: {
x: props.anchor().x,
y: props.anchor().y,
@ -250,7 +290,10 @@ export function Autocomplete(props: {
}
function hide() {
if (store.visible === "/" && !props.value.endsWith(" ")) props.input().value = ""
if (store.visible === "/" && !props.value.endsWith(" ")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
}
setStore("visible", false)
}
@ -262,12 +305,13 @@ export function Autocomplete(props: {
onInput(value: string) {
if (store.visible && value.length <= store.index) hide()
},
onKeyDown(e: ParsedKey) {
onKeyDown(e: KeyEvent) {
if (store.visible) {
if (e.name === "up") move(-1)
if (e.name === "down") move(1)
if (e.name === "escape") hide()
if (e.name === "return") select()
if (["up", "down", "return", "escape"].includes(e.name)) e.preventDefault()
}
if (!store.visible) {
if (e.name === "@") {
@ -278,7 +322,7 @@ export function Autocomplete(props: {
}
if (e.name === "/") {
if (props.input().cursorPosition === 0) show("/")
if (props.input().visualCursor.offset === 0) show("/")
}
}
},

View file

@ -5,11 +5,23 @@ import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile } from "fs/promises"
import type { AgentPart, FilePart } from "@opencode-ai/sdk"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
export type PromptInfo = {
input: string
parts: (Omit<FilePart, "id" | "messageID" | "sessionID"> | Omit<AgentPart, "id" | "messageID" | "sessionID">)[]
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
start: number
end: number
value: string
}
}
})
)[]
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({

View file

@ -1,7 +1,15 @@
import { InputRenderable, TextAttributes, BoxRenderable } from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX } from "solid-js"
import {
TextAttributes,
BoxRenderable,
TextareaRenderable,
MouseEvent,
KeyEvent,
SyntaxStyle,
PasteEvent,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
import { useLocal } from "@tui/context/local"
import { Theme } from "@tui/context/theme"
import { Theme, syntaxTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
@ -11,7 +19,6 @@ import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { iife } from "@/util/iife"
import { useCommandDialog } from "../dialog-command"
import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
@ -34,7 +41,7 @@ export type PromptRef = {
}
export function Prompt(props: PromptProps) {
let input: InputRenderable
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
@ -48,6 +55,11 @@ export function Prompt(props: PromptProps) {
const command = useCommandDialog()
const renderer = useRenderer()
const fileStyleId = syntaxTheme.getStyleId("extmark.file")!
const agentStyleId = syntaxTheme.getStyleId("extmark.agent")!
const pasteStyleId = syntaxTheme.getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
return [
{
@ -57,19 +69,20 @@ export function Prompt(props: PromptProps) {
value: "prompt.editor",
onSelect: async (dialog) => {
dialog.clear()
const value = input.value
input.value = ""
const value = input.plainText
input.clear()
setStore("prompt", {
input: "",
parts: [],
})
const content = await Editor.open({ value, renderer })
if (content) {
input.setText(content, { history: false })
setStore("prompt", {
input: content,
parts: [],
})
input.cursorPosition = content.length
input.cursorOffset = Bun.stringWidth(content)
}
},
},
@ -79,10 +92,12 @@ export function Prompt(props: PromptProps) {
disabled: true,
category: "Prompt",
onSelect: (dialog) => {
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
@ -117,18 +132,102 @@ export function Prompt(props: PromptProps) {
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
}>({
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
})
createEffect(() => {
input.focus()
})
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
parts.forEach((part, partIndex) => {
let start = 0
let end = 0
let virtualText = ""
let styleId: number | undefined
if (part.type === "file" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = fileStyleId
} else if (part.type === "agent" && part.source) {
start = part.source.start
end = part.source.end
virtualText = part.source.value
styleId = agentStyleId
} else if (part.type === "text" && part.source?.text) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
styleId = pasteStyleId
}
if (virtualText) {
const extmarkId = input.extmarks.create({
start,
end,
virtual: true,
styleId,
typeId: promptPartTypeId,
})
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}
})
}
function syncExtmarksWithPromptParts() {
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
setStore(
produce((draft) => {
const newMap = new Map<number, number>()
const newParts: typeof draft.prompt.parts = []
for (const extmark of allExtmarks) {
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = draft.prompt.parts[partIndex]
if (part) {
if (part.type === "agent" && part.source) {
part.source.start = extmark.start
part.source.end = extmark.end
} else if (part.type === "file" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
} else if (part.type === "text" && part.source?.text) {
part.source.text.start = extmark.start
part.source.text.end = extmark.end
}
newMap.set(extmark.id, newParts.length)
newParts.push(part)
}
}
}
draft.extmarkToPartIndex = newMap
draft.prompt.parts = newParts
}),
)
}
props.ref?.({
get focused() {
return input.focused
@ -140,14 +239,19 @@ export function Prompt(props: PromptProps) {
input.blur()
},
set(prompt) {
input.setText(prompt.input, { history: false })
setStore("prompt", prompt)
input.cursorPosition = prompt.input.length
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
})
@ -162,7 +266,7 @@ export function Prompt(props: PromptProps) {
return sessionID
})()
const messageID = Identifier.ascending("message")
const input = store.prompt.input
const inputText = store.prompt.input
if (store.mode === "shell") {
sdk.client.session.shell({
path: {
@ -170,12 +274,12 @@ export function Prompt(props: PromptProps) {
},
body: {
agent: local.agent.current().name,
command: input,
command: inputText,
},
})
setStore("mode", "normal")
} else if (input.startsWith("/")) {
const [command, ...args] = input.split(" ")
} else if (inputText.startsWith("/")) {
const [command, ...args] = inputText.split(" ")
sdk.client.session.command({
path: {
id: sessionID,
@ -202,7 +306,7 @@ export function Prompt(props: PromptProps) {
{
id: Identifier.ascending("part"),
type: "text",
text: input,
text: inputText,
},
...store.prompt.parts.map((x) => ({
id: Identifier.ascending("part"),
@ -213,10 +317,12 @@ export function Prompt(props: PromptProps) {
})
}
history.append(store.prompt)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
@ -227,6 +333,7 @@ export function Prompt(props: PromptProps) {
sessionID,
})
}, 50)
input.clear()
}
const exit = useExit()
@ -239,77 +346,79 @@ export function Prompt(props: PromptProps) {
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
input.cursorPosition = store.prompt.input.length
}}
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)}>
<box
flexDirection="row"
{...SplitBorder}
borderColor={keybind.leader ? Theme.accent : store.mode === "shell" ? Theme.secondary : undefined}
justifyContent="space-evenly"
>
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
<box
backgroundColor={Theme.backgroundElement}
width={3}
height="100%"
justifyContent="center"
alignItems="center"
>
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
{store.mode === "normal" ? ">" : "!"}
</text>
</box>
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
<input
onPaste={async function (text) {
this.insertText(text)
}}
onInput={(value) => {
let diff = value.length - store.prompt.input.length
setStore(
produce((draft) => {
draft.prompt.input = value
for (let i = 0; i < draft.prompt.parts.length; i++) {
const part = draft.prompt.parts[i]
if (!part.source) continue
const source = part.type === "agent" ? part.source : part.source.text
if (source.start >= input.cursorPosition) {
source.start += diff
source.end += diff
}
const sliced = draft.prompt.input.slice(source.start, source.end)
if (sliced != source.value && diff < 0) {
diff -= source.value.length
draft.prompt.input =
draft.prompt.input.slice(0, source.start) + draft.prompt.input.slice(source.end)
draft.prompt.parts.splice(i, 1)
input.cursorPosition = Math.max(0, source.start - 1)
i--
}
}
}),
)
<box paddingTop={1} paddingBottom={1} backgroundColor={Theme.backgroundElement} flexGrow={1}>
<textarea
minHeight={1}
maxHeight={6}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
autocomplete.onInput(value)
syncExtmarksWithPromptParts()
}}
value={store.prompt.input}
onKeyDown={async (e) => {
keyBindings={[
{ name: "return", meta: true, action: "newline" },
{ name: "enter", meta: true, action: "newline" },
{ name: "return", action: "submit" },
{ name: "enter", action: "submit" },
]}
onKeyDown={async (e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
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)) {
await exit()
return
}
if (e.name === "!" && input.cursorPosition === 0) {
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.mode === "shell") {
if ((e.name === "backspace" && input.cursorPosition === 0) || e.name === "escape") {
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
setStore("mode", "normal")
e.preventDefault()
return
@ -317,16 +426,19 @@ export function Prompt(props: PromptProps) {
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (e.name === "up" || e.name === "down") {
if (e.option && (e.name === "up" || e.name === "down")) {
const direction = e.name === "up" ? -1 : 1
const item = history.move(direction, input.value)
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input, { history: false })
setStore("prompt", item)
input.cursorPosition = item.input.length
restoreExtmarksFromParts(item.parts)
e.preventDefault()
}
return
}
}
if (!autocomplete.visible) {
if (e.name === "escape" && props.sessionID) {
sdk.client.session.abort({
path: {
@ -336,35 +448,60 @@ export function Prompt(props: PromptProps) {
return
}
}
const old = input.cursorPosition
setTimeout(() => {
const position = input.cursorPosition
const direction = Math.sign(old - position)
for (const part of store.prompt.parts) {
const source = iife(() => {
if (part.type === "agent") return part.source
if (part.type === "file") return part.source?.text
return
})
if (source) {
if (position >= source.start && position < source.end) {
if (direction === 1) {
input.cursorPosition = Math.max(0, source.start - 1)
}
if (direction === -1) {
input.cursorPosition = source.end
}
}
}
}
}, 0)
}}
onSubmit={submit}
ref={(r) => (input = r)}
onMouseDown={(r) => r.target?.focus()}
onPaste={(event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
}
event.preventDefault()
const pastedContent = event.text.trim()
if (!pastedContent) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
const currentOffset = input.visualCursor.offset
const virtualText = `[Pasted ~${lineCount} lines]`
const textToInsert = virtualText + " "
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length
input.insertText(textToInsert)
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})
const part = {
type: "text" as const,
text: pastedContent,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: virtualText,
},
},
}
setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push(part)
draft.extmarkToPartIndex.set(extmarkId, partIndex)
}),
)
}}
ref={(r: TextareaRenderable) => (input = r)}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={Theme.backgroundElement}
cursorColor={Theme.primary}
backgroundColor={Theme.backgroundElement}
syntaxStyle={syntaxTheme}
/>
</box>
<box backgroundColor={Theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>

View file

@ -259,6 +259,28 @@ const syntaxThemeDark = [
foreground: "#56b6c2",
},
},
{
scope: ["extmark.file"],
style: {
foreground: "#f5a742",
bold: true,
},
},
{
scope: ["extmark.agent"],
style: {
foreground: "#fab283",
bold: true,
},
},
{
scope: ["extmark.paste"],
style: {
foreground: "#0a0a0a",
background: "#f5a742",
bold: true,
},
},
{
scope: ["comment"],
style: {