diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 0280982d5074..35735ebb0fc3 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -21,6 +21,9 @@ import { mentionTriggerIndex, isNewCommand, movePromptHistory, + promptOffsetWidth, + promptOnFirstRow, + promptOnLastRow, pushPromptHistory, } from "./prompt.shared" import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap" @@ -591,7 +594,7 @@ export function createPromptState(input: PromptInput): PromptState { }) } - const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => { + const restore = (value: RunPrompt, cursor = promptOffsetWidth(value.text)) => { draft = clonePrompt(value) setShell(value.mode === "shell") if (!area || area.isDestroyed) { @@ -601,7 +604,7 @@ export function createPromptState(input: PromptInput): PromptState { hide() area.setText(value.text) restoreParts(value.parts) - area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText)) + area.cursorOffset = Math.min(cursor, promptOffsetWidth(area.plainText)) scheduleRows() area.focus() } @@ -632,7 +635,7 @@ export function createPromptState(input: PromptInput): PromptState { area.setText(text) clearParts() draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] } - area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText)) + area.cursorOffset = Math.min(promptOffsetWidth(text), promptOffsetWidth(area.plainText)) scheduleRows() area.focus() } @@ -766,19 +769,15 @@ export function createPromptState(input: PromptInput): PromptState { if (move(dir, event)) return if (!area || area.isDestroyed) return false - const endOffset = Bun.stringWidth(area.plainText) - if (dir === -1 && area.visualCursor.visualRow === 0) { - area.cursorOffset = 0 + if (dir === -1 && promptOnFirstRow(area)) { + area.gotoBufferHome() } - const end = - typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0 - ? area.height - 1 - : Math.max(0, (area.virtualLineCount ?? 1) - 1) - if (dir === 1 && area.visualCursor.visualRow === end) { - area.cursorOffset = endOffset + if (dir === 1 && promptOnLastRow(area)) { + area.gotoBufferEnd() } + // Reject so the textarea layer still moves the cursor one row. return false } @@ -871,13 +870,13 @@ export function createPromptState(input: PromptInput): PromptState { shell() || !head ? cursor : local - ? Bun.stringWidth(area.plainText) - : Bun.stringWidth(area.plainText.slice(0, head.end)) + ? promptOffsetWidth(area.plainText) + : promptOffsetWidth(area.plainText.slice(0, head.end)) const end = area.logicalCursor area.deleteRange(start.row, start.col, end.row, end.col) area.insertText(text) - area.cursorOffset = Bun.stringWidth(text) + area.cursorOffset = promptOffsetWidth(text) hide() syncDraft() if (!shell()) { @@ -902,7 +901,7 @@ export function createPromptState(input: PromptInput): PromptState { const text = "@" + next.value const startOffset = at() - const endOffset = startOffset + Bun.stringWidth(text) + const endOffset = startOffset + promptOffsetWidth(text) const part = structuredClone(next.part) if (part.type === "agent") { part.source = { diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 63c33aa34ffd..0344fc5edaac 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -7,7 +7,15 @@ // 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. -export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" +export { + displayCharAt, + displaySlice, + mentionTriggerIndex, + promptOffsetWidth, + promptOnFirstRow, + promptOnLastRow, +} from "../prompt-display" +import { promptOffsetWidth } from "../prompt-display" import type { RunPrompt } from "./types" const HISTORY_LIMIT = 200 @@ -102,7 +110,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: return { state, apply: false } } - if (dir === 1 && cursor !== Bun.stringWidth(text)) { + if (dir === 1 && cursor !== promptOffsetWidth(text)) { return { state, apply: false } } @@ -136,7 +144,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: null, }, text: state.draft, - cursor: Bun.stringWidth(state.draft), + cursor: promptOffsetWidth(state.draft), apply: true, } } @@ -147,7 +155,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: idx, }, text: state.items[idx].text, - cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text), + cursor: dir === -1 ? 0 : promptOffsetWidth(state.items[idx].text), apply: true, } } diff --git a/packages/opencode/test/cli/run/footer.prompt.cursor.test.tsx b/packages/opencode/test/cli/run/footer.prompt.cursor.test.tsx new file mode 100644 index 000000000000..1df981477e7b --- /dev/null +++ b/packages/opencode/test/cli/run/footer.prompt.cursor.test.tsx @@ -0,0 +1,208 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender, useRenderer } from "@opentui/solid" +import { createSignal } from "solid-js" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap" +import { RunFooterView } from "@/cli/cmd/run/footer.view" +import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme" +import { promptOffsetWidth } from "@opencode-ai/tui/prompt/display" +import type { FooterState, FooterSubagentState, FooterView, RunPrompt } from "@/cli/cmd/run/types" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const tuiConfig = createTuiResolvedConfig() + +async function renderComposer(input: { history?: RunPrompt[] } = {}) { + const [view] = createSignal({ type: "prompt" }) + const [subagents] = createSignal({ tabs: [], details: {}, permissions: [], questions: [] }) + const [state] = createSignal({ + phase: "idle", + status: "", + queue: 0, + model: "gpt-5", + duration: "", + usage: "", + first: true, + interrupt: 0, + exit: 0, + }) + let offKeymap: (() => void) | undefined + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig) + + return ( + + []} + agents={() => []} + resources={() => []} + commands={() => []} + providers={() => undefined} + currentModel={() => undefined} + variants={() => []} + currentVariant={() => undefined} + state={state} + view={view} + subagent={subagents} + theme={() => RUN_THEME_FALLBACK} + tuiConfig={tuiConfig} + backgroundSubagents={true} + agent="opencode" + history={input.history} + onSubmit={() => true} + onPermissionReply={() => {}} + onQuestionReply={() => {}} + onQuestionReject={() => {}} + onCycle={() => {}} + onInterrupt={() => false} + onEditorOpen={async () => undefined} + onInputClear={() => {}} + onExit={() => {}} + onModelSelect={() => {}} + onVariantSelect={() => {}} + onRows={() => {}} + onLayout={() => {}} + onStatus={() => {}} + onQueuedRemove={async () => true} + /> + + ) + } + + const app = await testRender( + () => ( + + + + ), + { width: 40, height: 16, kittyKeyboard: true }, + ) + await app.renderOnce() + + return { + ...app, + area() { + return app.renderer.currentFocusedEditor! + }, + async press(dir: "up" | "down", times: number) { + for (let i = 0; i < times; i++) { + app.mockInput.pressArrow(dir) + await app.renderOnce() + } + }, + cleanup() { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + offKeymap?.() + offKeymap = undefined + app.renderer.destroy() + }, + } +} + +test("direct composer down arrow walks a multi-line prompt to its end", async () => { + const app = await renderComposer() + + try { + const area = app.area() + area.setText("one\ntwo\nthree") + await app.renderOnce() + const end = promptOffsetWidth(area.plainText) + + // Middle of the second line. Each newline costs one offset position, so the + // end offset is 13 here while Bun.stringWidth would report 11. + area.cursorOffset = 5 + await app.renderOnce() + await app.press("down", 1) + expect(area.cursorOffset).toBe(9) + + await app.press("down", 3) + expect(area.cursorOffset).toBe(end) + } finally { + app.cleanup() + } +}) + +test("direct composer down arrow reaches the end of wide-character text", async () => { + const app = await renderComposer() + + try { + const area = app.area() + area.setText("你好世界\n第二行文字\n第三行") + await app.renderOnce() + + area.gotoBufferHome() + await app.renderOnce() + await app.press("down", 6) + expect(area.cursorOffset).toBe(promptOffsetWidth(area.plainText)) + } finally { + app.cleanup() + } +}) + +test("direct composer arrows never move backwards in a scrolled prompt", async () => { + const app = await renderComposer() + + try { + const area = app.area() + // Eight lines against TEXTAREA_MAX_ROWS (6) forces the viewport to scroll. + area.setText("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8") + await app.renderOnce() + area.gotoBufferHome() + await app.renderOnce() + + const seen = [area.cursorOffset] + for (let i = 0; i < 9; i++) { + app.mockInput.pressArrow("down") + await app.renderOnce() + expect(area.cursorOffset).toBeGreaterThanOrEqual(seen[seen.length - 1]) + seen.push(area.cursorOffset) + } + expect(area.cursorOffset).toBe(promptOffsetWidth(area.plainText)) + } finally { + app.cleanup() + } +}) + +test("direct composer up arrow walks a multi-line prompt to its start", async () => { + const app = await renderComposer() + + try { + const area = app.area() + area.setText("你好世界\n第二行文字\n第三行") + await app.renderOnce() + area.gotoBufferEnd() + await app.renderOnce() + + await app.press("up", 6) + expect(area.cursorOffset).toBe(0) + } finally { + app.cleanup() + } +}) + +test("direct composer recalls history from the end of a multi-line draft", async () => { + const app = await renderComposer({ history: [{ text: "older prompt", parts: [] }] }) + + try { + const area = app.area() + area.setText("draft one\ndraft two") + await app.renderOnce() + area.gotoBufferEnd() + await app.renderOnce() + + // Up walks to the top of the draft, then swaps in the history entry. + await app.press("up", 3) + expect(area.plainText).toBe("older prompt") + + // Down at the end of the entry restores the draft rather than stalling. + await app.press("down", 2) + expect(area.plainText).toBe("draft one\ndraft two") + } finally { + app.cleanup() + } +}) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 00efcbed2887..f57c7d02cf3e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -28,7 +28,7 @@ import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" import { useExit } from "../../context/exit" -import { promptOffsetWidth } from "../../prompt/display" +import { promptOffsetWidth, promptOnFirstRow, promptOnLastRow } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" @@ -508,7 +508,7 @@ export function Prompt(props: PromptProps) { parts: updatedNonTextParts, }) restoreExtmarksFromParts(updatedNonTextParts) - input.cursorOffset = Bun.stringWidth(normalized) + input.gotoBufferEnd() }, }, { @@ -872,7 +872,7 @@ export function Prompt(props: PromptProps) { category: "Prompt", run() { if (input.cursorOffset !== 0) { - if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0 + if (promptOnFirstRow(input)) input.gotoBufferHome() return false } @@ -903,12 +903,8 @@ export function Prompt(props: PromptProps) { title: "Next prompt history", category: "Prompt", run() { - if (input.cursorOffset !== input.plainText.length) { - if ( - input.scrollY + input.visualCursor.visualRow === - Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) - ) - input.cursorOffset = input.plainText.length + if (input.cursorOffset !== promptOffsetWidth(input.plainText)) { + if (promptOnLastRow(input)) input.gotoBufferEnd() return false } @@ -918,7 +914,7 @@ export function Prompt(props: PromptProps) { setStore("prompt", item) setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) - input.cursorOffset = input.plainText.length + input.gotoBufferEnd() }, }, ], diff --git a/packages/tui/src/prompt/display.ts b/packages/tui/src/prompt/display.ts index 4c22942ea897..000f9796d3a4 100644 --- a/packages/tui/src/prompt/display.ts +++ b/packages/tui/src/prompt/display.ts @@ -1,14 +1,41 @@ +import type { EditBufferRenderable } from "@opentui/core" + const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) export function promptOffsetWidth(value: string) { let width = 0 for (const part of graphemes.segment(value)) { - // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. - width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment) + // Textarea offsets count newlines as one position and tabs as two, while + // Bun.stringWidth counts both as zero. + if (part.segment === "\n") { + width += 1 + continue + } + if (part.segment === "\t") { + width += 2 + continue + } + + // The widget measures a cluster by the width of the character it renders, + // so decomposed input (IME-committed hangul jamo, か + combining dakuten) + // has to be composed first or each piece gets counted on its own. Only + // multi-codepoint clusters can decompose, so single ones skip the work. + width += Bun.stringWidth(part.segment.length > 1 ? part.segment.normalize("NFC") : part.segment) } return width } +// visualCursor.visualRow is viewport-relative, so scrollY is what makes it a +// document row. Comparing visualRow alone treats the top of a scrolled viewport +// as the first line of the buffer. +export function promptOnFirstRow(input: EditBufferRenderable) { + return input.scrollY + input.visualCursor.visualRow === 0 +} + +export function promptOnLastRow(input: EditBufferRenderable) { + return input.scrollY + input.visualCursor.visualRow === Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) +} + function displayOffsetIndex(value: string, offset: number) { if (offset <= 0) return 0 diff --git a/packages/tui/test/prompt/display-offset.test.tsx b/packages/tui/test/prompt/display-offset.test.tsx new file mode 100644 index 000000000000..5ea29ef9db56 --- /dev/null +++ b/packages/tui/test/prompt/display-offset.test.tsx @@ -0,0 +1,152 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { TextareaRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { promptOffsetWidth, promptOnFirstRow, promptOnLastRow } from "../../src/prompt/display" + +// The textarea's cursorOffset lives in a display-column space where a newline +// takes one position and a tab takes two, so neither Bun.stringWidth (newline +// and tab count as zero) nor String.length (wide characters count as one) can +// stand in for it. These tests pin promptOffsetWidth and the row predicates +// against what the real widget reports. +async function mount(width = 40) { + let area!: TextareaRenderable + const app = await testRender( + () => ( + +