mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Opentui textarea (#3399)
Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
parent
96498cf43c
commit
af0f40e3cb
6 changed files with 339 additions and 124 deletions
20
bun.lock
20
bun.lock
|
|
@ -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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue