Opentui textarea (#3310)

Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
This commit is contained in:
Dax 2025-10-23 21:02:02 -04:00 committed by GitHub
parent b3a4cbd7ce
commit 4c2d0cf181
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 31 deletions

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 | "@" | "/"
}
@ -28,7 +28,7 @@ export function Autocomplete(props: {
sessionID?: string
setPrompt: (input: (prompt: PromptInfo) => void) => void
anchor: () => BoxRenderable
input: () => InputRenderable
input: () => TextareaRenderable
ref: (ref: AutocompleteRef) => void
}) {
const sdk = useSDK()
@ -138,8 +138,9 @@ export function Autocomplete(props: {
display: "/" + command.name,
description: command.description,
onSelect: () => {
console.log("commands.onSelect", command.name, Bun.stringWidth(props.input().value))
props.input().value = "/" + command.name + " "
props.input().cursorPosition = props.input().value.length
props.input().cursorOffset = Bun.stringWidth(props.input().value)
},
})
}
@ -238,9 +239,10 @@ export function Autocomplete(props: {
}
function show(mode: "@" | "/") {
console.log("show", mode, props.input().visualCursor.offset)
setStore({
visible: mode,
index: props.input().cursorPosition,
index: props.input().visualCursor.offset,
position: {
x: props.anchor().x,
y: props.anchor().y,
@ -262,7 +264,7 @@ 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)
@ -278,7 +280,7 @@ export function Autocomplete(props: {
}
if (e.name === "/") {
if (props.input().cursorPosition === 0) show("/")
if (props.input().visualCursor.offset === 0) show("/")
}
}
},

View file

@ -1,4 +1,4 @@
import { InputRenderable, TextAttributes, BoxRenderable } from "@opentui/core"
import { TextAttributes, BoxRenderable, TextareaRenderable, MouseEvent, KeyEvent } from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX } from "solid-js"
import { useLocal } from "@tui/context/local"
import { Theme } from "@tui/context/theme"
@ -34,7 +34,7 @@ export type PromptRef = {
}
export function Prompt(props: PromptProps) {
let input: InputRenderable
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
@ -69,7 +69,8 @@ export function Prompt(props: PromptProps) {
input: content,
parts: [],
})
input.cursorPosition = content.length
console.log("editor.open", content, Bun.stringWidth(content))
input.cursorOffset = Bun.stringWidth(content)
}
},
},
@ -141,7 +142,8 @@ export function Prompt(props: PromptProps) {
},
set(prompt) {
setStore("prompt", prompt)
input.cursorPosition = prompt.input.length
console.log("prompt.set", prompt.input, Bun.stringWidth(prompt.input))
input.cursorOffset = Bun.stringWidth(prompt.input)
},
reset() {
setStore("prompt", {
@ -239,7 +241,8 @@ export function Prompt(props: PromptProps) {
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
input.cursorPosition = store.prompt.input.length
console.log("setPrompt", store.prompt.input, Bun.stringWidth(store.prompt.input))
input.cursorOffset = Bun.stringWidth(store.prompt.input)
}}
value={store.prompt.input}
/>
@ -248,18 +251,23 @@ export function Prompt(props: PromptProps) {
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) => {
<box paddingTop={1} paddingBottom={1} backgroundColor={Theme.backgroundElement} flexGrow={1}>
<textarea
onContentChange={() => {
const value = input.value
let diff = value.length - store.prompt.input.length
setStore(
produce((draft) => {
@ -268,7 +276,7 @@ export function Prompt(props: PromptProps) {
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) {
if (source.start >= input.visualCursor.offset) {
source.start += diff
source.end += diff
}
@ -278,7 +286,14 @@ export function Prompt(props: PromptProps) {
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)
console.log(
"onContentChange setting cursor offset",
value,
input.visualCursor.offset,
source.start,
source.end,
)
input.cursorOffset = Math.max(0, source.start - 1)
i--
}
}
@ -287,7 +302,7 @@ export function Prompt(props: PromptProps) {
autocomplete.onInput(value)
}}
value={store.prompt.input}
onKeyDown={async (e) => {
onKeyDown={async (e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
@ -303,29 +318,32 @@ export function Prompt(props: PromptProps) {
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
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (!autocomplete.visible && input.visualCursor.offset === 0) {
if (e.name === "up" || e.name === "down") {
const direction = e.name === "up" ? -1 : 1
const item = history.move(direction, input.value)
if (item) {
setStore("prompt", item)
input.cursorPosition = item.input.length
e.preventDefault()
}
return
}
}
if (!autocomplete.visible) {
if (e.name === "escape" && props.sessionID) {
sdk.client.session.abort({
path: {
@ -335,9 +353,9 @@ export function Prompt(props: PromptProps) {
return
}
}
const old = input.cursorPosition
const old = input.visualCursor.offset
setTimeout(() => {
const position = input.cursorPosition
const position = input.visualCursor.offset
const direction = Math.sign(old - position)
for (const part of store.prompt.parts) {
const source = iife(() => {
@ -348,10 +366,12 @@ export function Prompt(props: PromptProps) {
if (source) {
if (position >= source.start && position < source.end) {
if (direction === 1) {
input.cursorPosition = Math.max(0, source.start - 1)
console.log("onKeyDown setting cursor offset", source.start - 1)
input.cursorOffset = Math.max(0, source.start - 1)
}
if (direction === -1) {
input.cursorPosition = source.end
console.log("onKeyDown setting cursor offset", source.end)
input.cursorOffset = source.end
}
}
}
@ -359,8 +379,8 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onSubmit={submit}
ref={(r) => (input = r)}
onMouseDown={(r) => r.target?.focus()}
ref={(r: TextareaRenderable) => (input = r)}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={Theme.backgroundElement}
cursorColor={Theme.primary}
backgroundColor={Theme.backgroundElement}