tui: simplify prompt input sync
Drop the burst-aware key interceptor that tried to keep derived prompt state current for every control binding. Frame-batch content updates only, and let exit, clear, stash, and autocomplete read or flush live textarea state at the command boundary instead.
This commit is contained in:
parent
9f72479515
commit
f870e771e9
6 changed files with 65 additions and 227 deletions
|
|
@ -15,6 +15,7 @@ import {
|
||||||
MouseButton,
|
MouseButton,
|
||||||
type CliRenderer,
|
type CliRenderer,
|
||||||
type CliRendererConfig,
|
type CliRendererConfig,
|
||||||
|
type KeyEvent,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "./context/route"
|
import { RouteProvider, useRoute } from "./context/route"
|
||||||
|
|
@ -962,7 +963,11 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
name: "app.exit",
|
name: "app.exit",
|
||||||
title: "Exit the app",
|
title: "Exit the app",
|
||||||
slash: { name: "exit", aliases: ["quit", "q"] },
|
slash: { name: "exit", aliases: ["quit", "q"] },
|
||||||
run: () => exit(),
|
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||||
|
const current = promptRef.current
|
||||||
|
if (event?.sequence && current?.focused && !current.empty) return false
|
||||||
|
exit()
|
||||||
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1118,14 +1123,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
bindings: pinnedSessionBindingCommands,
|
bindings: pinnedSessionBindingCommands,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
|
||||||
enabled: () => {
|
|
||||||
const current = promptRef.current
|
|
||||||
if (!current?.focused) return true
|
|
||||||
return current.current.text === ""
|
|
||||||
},
|
|
||||||
bindings: ["app.exit"],
|
|
||||||
}))
|
|
||||||
|
|
||||||
event.on("tui.command.execute", (evt, { workspace }) => {
|
event.on("tui.command.execute", (evt, { workspace }) => {
|
||||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,10 @@ export function Autocomplete(props: {
|
||||||
visible: false as AutocompleteRef["visible"],
|
visible: false as AutocompleteRef["visible"],
|
||||||
input: "keyboard" as "keyboard" | "mouse",
|
input: "keyboard" as "keyboard" | "mouse",
|
||||||
})
|
})
|
||||||
|
let popMode: (() => void) | undefined
|
||||||
|
|
||||||
const [positionTick, setPositionTick] = createSignal(0)
|
const [positionTick, setPositionTick] = createSignal(0)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.visible) return
|
|
||||||
const popMode = keymap.mode.push("autocomplete")
|
|
||||||
onCleanup(popMode)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (store.visible) {
|
if (store.visible) {
|
||||||
let lastPos = { x: 0, y: 0, width: 0 }
|
let lastPos = { x: 0, y: 0, width: 0 }
|
||||||
|
|
@ -271,7 +266,7 @@ export function Autocomplete(props: {
|
||||||
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
|
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
|
||||||
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
||||||
|
|
||||||
setStore("visible", false)
|
hide(false)
|
||||||
setStore("index", index)
|
setStore("index", index)
|
||||||
insertPart(filename, part)
|
insertPart(filename, part)
|
||||||
}
|
}
|
||||||
|
|
@ -500,6 +495,7 @@ export function Autocomplete(props: {
|
||||||
|
|
||||||
function move(direction: -1 | 1) {
|
function move(direction: -1 | 1) {
|
||||||
if (!store.visible) return
|
if (!store.visible) return
|
||||||
|
syncSearch()
|
||||||
if (!options().length) return
|
if (!options().length) return
|
||||||
let next = store.selected + direction
|
let next = store.selected + direction
|
||||||
if (next < 0) next = options().length - 1
|
if (next < 0) next = options().length - 1
|
||||||
|
|
@ -520,6 +516,7 @@ export function Autocomplete(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function select() {
|
function select() {
|
||||||
|
syncSearch()
|
||||||
const selected = options()[store.selected]
|
const selected = options()[store.selected]
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
hide()
|
hide()
|
||||||
|
|
@ -591,6 +588,7 @@ export function Autocomplete(props: {
|
||||||
title: "Complete autocomplete item",
|
title: "Complete autocomplete item",
|
||||||
group: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
|
syncSearch()
|
||||||
const selected = options()[store.selected]
|
const selected = options()[store.selected]
|
||||||
if (selected?.isDirectory) {
|
if (selected?.isDirectory) {
|
||||||
expandDirectory()
|
expandDirectory()
|
||||||
|
|
@ -604,15 +602,16 @@ export function Autocomplete(props: {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function show(mode: "@" | "/") {
|
function show(mode: "@" | "/") {
|
||||||
|
popMode ??= keymap.mode.push("autocomplete")
|
||||||
setStore({
|
setStore({
|
||||||
visible: mode,
|
visible: mode,
|
||||||
index: props.input().cursorOffset,
|
index: props.input().cursorOffset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide(clear = true) {
|
||||||
const text = props.input().plainText
|
const text = props.input().plainText
|
||||||
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
|
if (clear && store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
|
||||||
const cursor = props.input().logicalCursor
|
const cursor = props.input().logicalCursor
|
||||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||||
// Sync the prompt store immediately since onContentChange is async
|
// Sync the prompt store immediately since onContentChange is async
|
||||||
|
|
@ -621,6 +620,8 @@ export function Autocomplete(props: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setStore("visible", false)
|
setStore("visible", false)
|
||||||
|
popMode?.()
|
||||||
|
popMode = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -632,35 +633,33 @@ export function Autocomplete(props: {
|
||||||
unsubscribeMention()
|
unsubscribeMention()
|
||||||
})
|
})
|
||||||
|
|
||||||
props.ref({
|
const ref = {
|
||||||
get visible() {
|
get visible() {
|
||||||
return store.visible
|
return store.visible
|
||||||
},
|
},
|
||||||
onInput(value) {
|
onInput(value?: string) {
|
||||||
|
if (!props.input().focused) return
|
||||||
if (store.visible) {
|
if (store.visible) {
|
||||||
if (
|
if (
|
||||||
// Typed text before the trigger
|
// Typed text before the trigger
|
||||||
props.input().cursorOffset <= store.index ||
|
props.input().cursorOffset <= store.index ||
|
||||||
// There is a space between the trigger and the cursor
|
// There is a space between the trigger and the cursor
|
||||||
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
|
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
|
||||||
// "/<command>" is not the sole content
|
|
||||||
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
|
|
||||||
) {
|
) {
|
||||||
hide()
|
hide(false)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
|
|
||||||
const offset = props.input().cursorOffset
|
const offset = props.input().cursorOffset
|
||||||
if (offset === 0) return
|
if (offset === 0) return
|
||||||
|
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
|
||||||
// Check for "/" at position 0 - reopen slash commands
|
if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
|
||||||
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
|
|
||||||
show("/")
|
show("/")
|
||||||
setStore("index", 0)
|
setStore("index", 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (value === undefined) return
|
||||||
|
|
||||||
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
|
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
|
||||||
const idx = mentionTriggerIndex(value, offset)
|
const idx = mentionTriggerIndex(value, offset)
|
||||||
|
|
@ -669,9 +668,22 @@ export function Autocomplete(props: {
|
||||||
setStore("index", idx)
|
setStore("index", idx)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
props.ref(ref)
|
||||||
|
const stopInputSync = keymap.intercept("key", () => ref.onInput())
|
||||||
|
onCleanup(() => {
|
||||||
|
stopInputSync()
|
||||||
|
popMode?.()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function syncSearch() {
|
||||||
|
const next = props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||||
|
if (next === search()) return
|
||||||
|
setSearch(next)
|
||||||
|
setStore("selected", 0)
|
||||||
|
}
|
||||||
|
|
||||||
const height = createMemo(() => {
|
const height = createMemo(() => {
|
||||||
const count = options().length || 1
|
const count = options().length || 1
|
||||||
if (!store.visible) return Math.min(10, count)
|
if (!store.visible) return Math.min(10, count)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ function pastedFilepath(value: string, platform: string) {
|
||||||
|
|
||||||
export type PromptRef = {
|
export type PromptRef = {
|
||||||
focused: boolean
|
focused: boolean
|
||||||
|
empty: boolean
|
||||||
current: PromptInfo
|
current: PromptInfo
|
||||||
set(prompt: PromptInfo): void
|
set(prompt: PromptInfo): void
|
||||||
reset(): void
|
reset(): void
|
||||||
|
|
@ -94,20 +95,6 @@ export type PromptRef = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||||
const PROMPT_SYNC_COMMANDS = [
|
|
||||||
"app.exit",
|
|
||||||
"prompt.clear",
|
|
||||||
"prompt.submit",
|
|
||||||
"prompt.editor",
|
|
||||||
"prompt.stash",
|
|
||||||
"prompt.stash.pop",
|
|
||||||
"prompt.stash.list",
|
|
||||||
"prompt.autocomplete.prev",
|
|
||||||
"prompt.autocomplete.next",
|
|
||||||
"prompt.autocomplete.hide",
|
|
||||||
"prompt.autocomplete.select",
|
|
||||||
"prompt.autocomplete.complete",
|
|
||||||
]
|
|
||||||
|
|
||||||
function randomIndex(count: number) {
|
function randomIndex(count: number) {
|
||||||
if (count <= 0) return 0
|
if (count <= 0) return 0
|
||||||
|
|
@ -162,8 +149,6 @@ export function Prompt(props: PromptProps) {
|
||||||
let input: TextareaRenderable
|
let input: TextareaRenderable
|
||||||
let anchor: BoxRenderable
|
let anchor: BoxRenderable
|
||||||
let promptSyncQueued = false
|
let promptSyncQueued = false
|
||||||
let promptContentChanged = false
|
|
||||||
let promptTextInputPending = false
|
|
||||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||||
|
|
||||||
const leader = Keymap.useLeaderActive()
|
const leader = Keymap.useLeaderActive()
|
||||||
|
|
@ -185,53 +170,7 @@ export function Prompt(props: PromptProps) {
|
||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
const activeKeys = Keymap.useActiveKeys()
|
|
||||||
const commandKeys = Keymap.useCommandKeys(() => PROMPT_SYNC_COMMANDS)
|
|
||||||
// Commands must see earlier burst text, while native textarea edits remain frame-batched.
|
|
||||||
const stopPromptSyncInterceptor = keymap.intercept("key", ({ event }) => {
|
|
||||||
const code = event.sequence.charCodeAt(0)
|
|
||||||
const textInput =
|
|
||||||
!event.ctrl &&
|
|
||||||
!event.meta &&
|
|
||||||
!event.super &&
|
|
||||||
!event.hyper &&
|
|
||||||
(event.name === "space" || (code >= 32 && code !== 127))
|
|
||||||
const pending = promptSyncQueued || promptTextInputPending
|
|
||||||
const rawBase = event.baseCode === undefined ? undefined : String.fromCodePoint(event.baseCode)
|
|
||||||
const base = rawBase && rawBase >= "A" && rawBase <= "Z" ? rawBase.toLowerCase() : rawBase
|
|
||||||
const configured = commandKeys().some(
|
|
||||||
(stroke) =>
|
|
||||||
(stroke.name === event.name || stroke.name === base) &&
|
|
||||||
stroke.ctrl === event.ctrl &&
|
|
||||||
stroke.shift === event.shift &&
|
|
||||||
stroke.meta === event.meta &&
|
|
||||||
stroke.super === !!event.super &&
|
|
||||||
(stroke.hyper ?? false) === !!event.hyper,
|
|
||||||
)
|
|
||||||
const matched = pending
|
|
||||||
? activeKeys().filter(
|
|
||||||
(key) =>
|
|
||||||
(key.stroke.name === event.name || key.stroke.name === base) &&
|
|
||||||
key.stroke.ctrl === event.ctrl &&
|
|
||||||
key.stroke.shift === event.shift &&
|
|
||||||
key.stroke.meta === event.meta &&
|
|
||||||
key.stroke.super === !!event.super &&
|
|
||||||
(key.stroke.hyper ?? false) === !!event.hyper,
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
const bound = matched.some((key) => typeof key.command !== "string" || !key.command.startsWith("input."))
|
|
||||||
if (textInput && (!input?.focused || !pending || (!bound && !configured))) {
|
|
||||||
if (input?.focused) promptTextInputPending = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!textInput && matched.length > 0 && !bound && !configured) return
|
|
||||||
const value = promptTextInputPending && input && !input.isDestroyed ? input.plainText : undefined
|
|
||||||
promptTextInputPending = false
|
|
||||||
flushPromptSync(value)
|
|
||||||
if (textInput && input?.focused) promptTextInputPending = true
|
|
||||||
})
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const flushPromptSyncFrame = async () => flushPromptSync()
|
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
@ -409,6 +348,7 @@ export function Prompt(props: PromptProps) {
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
if (input.getTextRange(0, 1) === "") return false
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
|
|
@ -513,6 +453,7 @@ export function Prompt(props: PromptProps) {
|
||||||
name: "prompt.editor",
|
name: "prompt.editor",
|
||||||
slash: { name: "editor" },
|
slash: { name: "editor" },
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
if (promptSyncQueued) await flushPromptSync()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|
||||||
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
|
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
|
||||||
|
|
@ -605,6 +546,9 @@ export function Prompt(props: PromptProps) {
|
||||||
get focused() {
|
get focused() {
|
||||||
return input.focused
|
return input.focused
|
||||||
},
|
},
|
||||||
|
get empty() {
|
||||||
|
return input.getTextRange(0, 1) === ""
|
||||||
|
},
|
||||||
get current() {
|
get current() {
|
||||||
return store.prompt
|
return store.prompt
|
||||||
},
|
},
|
||||||
|
|
@ -644,11 +588,7 @@ export function Prompt(props: PromptProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stopPromptSyncInterceptor()
|
if (promptSyncQueued) void flushPromptSync()
|
||||||
flushPromptSync(!input || input.isDestroyed ? undefined : input.plainText)
|
|
||||||
if (promptSyncQueued) flushPromptSync(!input || input.isDestroyed ? undefined : input.plainText)
|
|
||||||
renderer.removeFrameCallback(flushPromptSyncFrame)
|
|
||||||
promptSyncQueued = false
|
|
||||||
if (store.prompt.text) {
|
if (store.prompt.text) {
|
||||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||||
}
|
}
|
||||||
|
|
@ -774,9 +714,9 @@ export function Prompt(props: PromptProps) {
|
||||||
title: "Stash prompt",
|
title: "Stash prompt",
|
||||||
name: "prompt.stash",
|
name: "prompt.stash",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
enabled: !!store.prompt.text,
|
|
||||||
run: () => {
|
run: () => {
|
||||||
if (!store.prompt.text) return
|
if (input.getTextRange(0, 1) === "") return false
|
||||||
|
void flushPromptSync()
|
||||||
stash.push({ prompt: store.prompt })
|
stash.push({ prompt: store.prompt })
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
input.clear()
|
input.clear()
|
||||||
|
|
@ -847,7 +787,7 @@ export function Prompt(props: PromptProps) {
|
||||||
Keymap.createLayer(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
enabled: () => inputTarget() !== undefined && !props.disabled,
|
||||||
bindings: ["prompt.clear"],
|
bindings: ["prompt.clear"],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -871,6 +811,10 @@ export function Prompt(props: PromptProps) {
|
||||||
title: "Shell mode",
|
title: "Shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
run: () => {
|
run: () => {
|
||||||
|
if (input.visualCursor.offset !== 0) {
|
||||||
|
input.insertText("!")
|
||||||
|
return
|
||||||
|
}
|
||||||
setStore("placeholder", randomIndex(shell().length))
|
setStore("placeholder", randomIndex(shell().length))
|
||||||
setStore("mode", "shell")
|
setStore("mode", "shell")
|
||||||
},
|
},
|
||||||
|
|
@ -993,6 +937,7 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitInner() {
|
async function submitInner() {
|
||||||
|
if (promptSyncQueued) await flushPromptSync()
|
||||||
// IME: double-defer may fire before onContentChange flushes the last
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
// composed character (e.g. Korean hangul) to the store, so read
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
// plainText directly and sync before any downstream reads.
|
// plainText directly and sync before any downstream reads.
|
||||||
|
|
@ -1270,30 +1215,20 @@ export function Prompt(props: PromptProps) {
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPromptSync(value?: string) {
|
async function flushPromptSync() {
|
||||||
const contentChanged = value !== undefined && value !== store.prompt.text
|
|
||||||
if (!promptSyncQueued && !contentChanged) return
|
|
||||||
promptSyncQueued = false
|
promptSyncQueued = false
|
||||||
renderer.removeFrameCallback(flushPromptSyncFrame)
|
renderer.removeFrameCallback(flushPromptSync)
|
||||||
const syncContent = promptContentChanged || contentChanged
|
|
||||||
promptContentChanged = false
|
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
if (syncContent) {
|
const value = input.plainText
|
||||||
const text = value ?? input.plainText
|
setStore("prompt", "text", value)
|
||||||
setStore("prompt", "text", text)
|
auto()?.onInput(value)
|
||||||
auto()?.onInput(text)
|
|
||||||
syncExtmarksWithPromptParts()
|
syncExtmarksWithPromptParts()
|
||||||
}
|
}
|
||||||
setCursorVersion((value) => value + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function queuePromptSync(contentChanged: boolean) {
|
function queuePromptSync() {
|
||||||
promptContentChanged ||= contentChanged
|
|
||||||
if (contentChanged) promptTextInputPending = false
|
|
||||||
if (promptSyncQueued) return
|
if (promptSyncQueued) return
|
||||||
promptSyncQueued = true
|
promptSyncQueued = true
|
||||||
// Keep derived prompt state to one update per rendered frame across split stdin chunks.
|
renderer.setFrameCallback(flushPromptSync)
|
||||||
renderer.setFrameCallback(flushPromptSyncFrame)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||||
|
|
@ -1337,6 +1272,7 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPrompt() {
|
function clearPrompt() {
|
||||||
|
if (promptSyncQueued) void flushPromptSync()
|
||||||
if (
|
if (
|
||||||
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
|
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
|
||||||
store.prompt.pasted.length > 0 ||
|
store.prompt.pasted.length > 0 ||
|
||||||
|
|
@ -1451,8 +1387,8 @@ export function Prompt(props: PromptProps) {
|
||||||
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||||
minHeight={1}
|
minHeight={1}
|
||||||
maxHeight={maxHeight()}
|
maxHeight={maxHeight()}
|
||||||
onContentChange={() => queuePromptSync(true)}
|
onContentChange={queuePromptSync}
|
||||||
onCursorChange={() => queuePromptSync(false)}
|
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||||
onKeyDown={(e: { preventDefault(): void }) => {
|
onKeyDown={(e: { preventDefault(): void }) => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
|
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
|
||||||
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
|
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
|
||||||
import { stringifyKeyStroke, type Binding, type CommandContext, type NormalizedKeyStroke } from "@opentui/keymap"
|
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
|
||||||
import {
|
import {
|
||||||
registerBackspacePopsPendingSequence,
|
registerBackspacePopsPendingSequence,
|
||||||
registerBaseLayoutFallback,
|
registerBaseLayoutFallback,
|
||||||
|
|
@ -337,17 +337,6 @@ function useActiveKeys() {
|
||||||
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
|
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCommandKeys(commands: Accessor<readonly string[]>): Accessor<readonly NormalizedKeyStroke[]> {
|
|
||||||
useValue()
|
|
||||||
return useKeymapSelector((keymap) => {
|
|
||||||
const ids = commands()
|
|
||||||
const bindings = keymap.getCommandBindings({ visibility: "registered", commands: ids })
|
|
||||||
return ids.flatMap((id) =>
|
|
||||||
(bindings.get(id) ?? []).flatMap((binding) => (binding.sequence[0] ? [binding.sequence[0].stroke] : [])),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function useState() {
|
function useState() {
|
||||||
const value = useValue()
|
const value = useValue()
|
||||||
const commands = useCommands()
|
const commands = useCommands()
|
||||||
|
|
@ -399,7 +388,6 @@ export const Keymap = {
|
||||||
useCommands,
|
useCommands,
|
||||||
usePendingSequence,
|
usePendingSequence,
|
||||||
useActiveKeys,
|
useActiveKeys,
|
||||||
useCommandKeys,
|
|
||||||
useState,
|
useState,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { expect, mock, test } from "bun:test"
|
import { expect, mock, test } from "bun:test"
|
||||||
import { TextareaRenderable } from "@opentui/core"
|
|
||||||
import { createTestRenderer } from "@opentui/core/testing"
|
import { createTestRenderer } from "@opentui/core/testing"
|
||||||
import { Effect, FileSystem } from "effect"
|
import { Effect, FileSystem } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
|
@ -215,87 +214,3 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("raw text bursts coalesce prompt synchronization without hiding text from control keys", async () => {
|
|
||||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
|
||||||
const core = await import("@opentui/core")
|
|
||||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
|
||||||
const events = createEventStream()
|
|
||||||
const calls = createFetch(undefined, events)
|
|
||||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { run } = await import("../src/app")
|
|
||||||
const task = Effect.runPromise(
|
|
||||||
run({
|
|
||||||
app: { name: "test", version: "test", channel: "test" },
|
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
|
||||||
config: { get: async () => ({ keybinds: { input_clear: "q, z" } }), update: async () => ({}) },
|
|
||||||
packages: { resolve: async () => undefined },
|
|
||||||
args: {},
|
|
||||||
log: () => {},
|
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
|
||||||
)
|
|
||||||
while (!(setup.renderer.currentFocusedEditor instanceof TextareaRenderable)) {
|
|
||||||
await Bun.sleep(10)
|
|
||||||
}
|
|
||||||
const input = setup.renderer.currentFocusedEditor
|
|
||||||
const readPlainText = Object.getOwnPropertyDescriptor(
|
|
||||||
Object.getPrototypeOf(TextareaRenderable.prototype),
|
|
||||||
"plainText",
|
|
||||||
)?.get?.bind(input)
|
|
||||||
if (!readPlainText) throw new Error("Textarea plainText getter is missing")
|
|
||||||
let plainTextReads = 0
|
|
||||||
Object.defineProperty(input, "plainText", {
|
|
||||||
get() {
|
|
||||||
plainTextReads++
|
|
||||||
return readPlainText()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const text = "x".repeat(999) + "!"
|
|
||||||
setup.renderer.stdin.emit("data", Buffer.from(text))
|
|
||||||
setup.renderer.stdin.emit("data", Buffer.from("\x04"))
|
|
||||||
await Bun.sleep(50)
|
|
||||||
|
|
||||||
expect(setup.renderer.isDestroyed).toBe(false)
|
|
||||||
expect(readPlainText()).toBe(text)
|
|
||||||
expect(plainTextReads).toBeLessThanOrEqual(3)
|
|
||||||
|
|
||||||
plainTextReads = 0
|
|
||||||
const dribbled = "y".repeat(100)
|
|
||||||
for (const character of dribbled) {
|
|
||||||
setup.renderer.stdin.emit("data", Buffer.from(character))
|
|
||||||
await Promise.resolve()
|
|
||||||
}
|
|
||||||
await Bun.sleep(50)
|
|
||||||
|
|
||||||
expect(readPlainText()).toBe(text + dribbled)
|
|
||||||
expect(plainTextReads).toBe(1)
|
|
||||||
|
|
||||||
plainTextReads = 0
|
|
||||||
for (const backspace of "\x7f".repeat(dribbled.length)) {
|
|
||||||
setup.renderer.stdin.emit("data", Buffer.from(backspace))
|
|
||||||
await Promise.resolve()
|
|
||||||
}
|
|
||||||
await Bun.sleep(50)
|
|
||||||
|
|
||||||
expect(readPlainText()).toBe(text)
|
|
||||||
expect(plainTextReads).toBe(1)
|
|
||||||
|
|
||||||
input.clear()
|
|
||||||
await Bun.sleep(50)
|
|
||||||
plainTextReads = 0
|
|
||||||
setup.renderer.stdin.emit("data", Buffer.from("az"))
|
|
||||||
await Bun.sleep(50)
|
|
||||||
|
|
||||||
expect(readPlainText()).toBe("")
|
|
||||||
expect(plainTextReads).toBeLessThanOrEqual(2)
|
|
||||||
|
|
||||||
setup.renderer.destroy()
|
|
||||||
await task
|
|
||||||
} finally {
|
|
||||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
||||||
await server.stop()
|
|
||||||
mock.restore()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,4 @@ describe("prompt display", () => {
|
||||||
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
|
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
|
||||||
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
|
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips display-width conversion when text has no mention", () => {
|
|
||||||
const value = {
|
|
||||||
includes: () => false,
|
|
||||||
[Symbol.toPrimitive]() {
|
|
||||||
throw new Error("display width conversion should be skipped")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(Reflect.apply(mentionTriggerIndex, undefined, [value])).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue