cloudaxe-opencode/packages/opencode/src/cli/cmd/run/prompt.shared.ts
Simon Klee 8f05bbfaa6
prompt: fix cursor math for wide characters (#27017)
String.length counts code points, not display columns, so CJK
characters and emoji that occupy two terminal cells caused
misaligned cursors, broken mention triggers, and incorrect
history restoration offsets.

Use Bun.stringWidth for now, we need an alternative for this.

Fix #26716
Close #26922
2026-05-12 11:45:28 +02:00

327 lines
7.8 KiB
TypeScript

// Pure state machine for the prompt input.
//
// Handles keybind parsing, history ring navigation, and the leader-key
// sequence for variant cycling. All functions are pure -- they take state
// in and return new state out, with no side effects.
//
// The history ring (PromptHistoryState) stores past prompts and tracks
// the current browse position. When the user arrows up at cursor offset 0,
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
//
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
// arms the leader, second press within the timeout fires the action.
import type { KeyBinding } from "@opentui/core"
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
import { formatBinding, parseBindings } from "./keymap.shared"
import type { FooterKeybinds, RunPrompt } from "./types"
const HISTORY_LIMIT = 200
export type PromptHistoryState = {
items: RunPrompt[]
index: number | null
draft: string
}
export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) {
return {
name: event.name === " " ? "space" : event.name,
ctrl: !!event.ctrl,
meta: !!event.meta,
shift: !!event.shift,
super: !!event.super,
leader: false,
}
}
type PromptInfo = ReturnType<typeof promptInfo>
export type PromptKeys = {
leaders: PromptInfo[]
cycles: PromptInfo[]
interrupts: PromptInfo[]
previous: PromptInfo[]
next: PromptInfo[]
clear: PromptInfo[]
bindings: KeyBinding[]
}
export type PromptCycle = {
arm: boolean
clear: boolean
cycle: boolean
consume: boolean
}
export type PromptMove = {
state: PromptHistoryState
text?: string
cursor?: number
apply: boolean
}
export function promptCopy(prompt: RunPrompt): RunPrompt {
return {
text: prompt.text,
parts: structuredClone(prompt.parts),
}
}
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
}
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
if (binding.event !== "press") {
return undefined
}
const first = binding.sequence[0]
const second = binding.sequence[1]
if (!first) {
return undefined
}
if (!second) {
return first.patternName || first.tokenName
? undefined
: {
name: first.stroke.name,
ctrl: first.stroke.ctrl,
meta: first.stroke.meta,
shift: first.stroke.shift,
super: first.stroke.super,
leader: false,
}
}
if (binding.sequence.length !== 2 || first.tokenName !== "leader" || second.patternName || second.tokenName) {
return undefined
}
return {
name: second.stroke.name,
ctrl: second.stroke.ctrl,
meta: second.stroke.meta,
shift: second.stroke.shift,
super: second.stroke.super,
leader: true,
}
}
export function promptBindings(bindings: FooterKeybinds["commandList"], leader: string): PromptInfo[] {
return parseBindings(bindings, leader).flatMap((binding) => {
const key = promptKey(binding)
return key ? [key] : []
})
}
function mapInputBindings(
bindings: FooterKeybinds["inputSubmit"],
leader: string,
action: "submit" | "newline",
): KeyBinding[] {
return promptBindings(bindings, leader).flatMap((key) => {
if (key.leader) {
return []
}
return [
{
name: key.name,
ctrl: key.ctrl || undefined,
meta: key.meta || undefined,
shift: key.shift || undefined,
super: key.super || undefined,
action,
},
]
})
}
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
return [
...mapInputBindings(keybinds.inputSubmit, keybinds.leader, "submit"),
...mapInputBindings(keybinds.inputNewline, keybinds.leader, "newline"),
]
}
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
return {
leaders: promptBindings([{ key: keybinds.leader }], keybinds.leader),
cycles: promptBindings(keybinds.variantCycle, keybinds.leader),
interrupts: promptBindings(keybinds.interrupt, keybinds.leader),
previous: promptBindings(keybinds.historyPrevious, keybinds.leader),
next: promptBindings(keybinds.historyNext, keybinds.leader),
clear: promptBindings(keybinds.inputClear, keybinds.leader),
bindings: textareaBindings(keybinds),
}
}
export function printableBinding(bindings: FooterKeybinds["commandList"], leader: string): string {
return formatBinding(bindings, leader)
}
export function isExitCommand(input: string): boolean {
const text = input.trim().toLowerCase()
return text === "/exit" || text === "/quit" || text === ":q"
}
export function isNewCommand(input: string): boolean {
return input.trim().toLowerCase() === "/new"
}
export function promptHit(bindings: PromptInfo[], event: PromptInfo): boolean {
return bindings.some(
(item) =>
item.name === event.name &&
item.ctrl === event.ctrl &&
item.meta === event.meta &&
item.shift === event.shift &&
item.super === event.super &&
item.leader === event.leader,
)
}
export function promptCycle(
armed: boolean,
event: PromptInfo,
leaders: PromptInfo[],
cycles: PromptInfo[],
): PromptCycle {
if (!armed && promptHit(leaders, event)) {
return {
arm: true,
clear: false,
cycle: false,
consume: true,
}
}
if (armed) {
return {
arm: false,
clear: true,
cycle: promptHit(cycles, { ...event, leader: true }),
consume: true,
}
}
if (!promptHit(cycles, event)) {
return {
arm: false,
clear: false,
cycle: false,
consume: false,
}
}
return {
arm: false,
clear: false,
cycle: true,
consume: true,
}
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
const next: RunPrompt[] = []
for (const item of list) {
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
continue
}
next.push(item)
}
return {
items: next.slice(-HISTORY_LIMIT),
index: null,
draft: "",
}
}
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
if (!prompt.text.trim()) {
return state
}
const next = promptCopy(prompt)
if (state.items[state.items.length - 1] && promptSame(state.items[state.items.length - 1], next)) {
return {
...state,
index: null,
draft: "",
}
}
const items = [...state.items, next].slice(-HISTORY_LIMIT)
return {
...state,
items,
index: null,
draft: "",
}
}
export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptMove {
if (state.items.length === 0) {
return { state, apply: false }
}
if (dir === -1 && cursor !== 0) {
return { state, apply: false }
}
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
return { state, apply: false }
}
if (state.index === null) {
if (dir === 1) {
return { state, apply: false }
}
const idx = state.items.length - 1
return {
state: {
...state,
index: idx,
draft: text,
},
text: state.items[idx].text,
cursor: 0,
apply: true,
}
}
const idx = state.index + dir
if (idx < 0) {
return { state, apply: false }
}
if (idx >= state.items.length) {
return {
state: {
...state,
index: null,
},
text: state.draft,
cursor: Bun.stringWidth(state.draft),
apply: true,
}
}
return {
state: {
...state,
index: idx,
},
text: state.items[idx].text,
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
apply: true,
}
}