From cc483ff1ffa566fbe448f40f7bb8575e90da31a8 Mon Sep 17 00:00:00 2001 From: YangXiao <3351163616@qq.com> Date: Sun, 2 Aug 2026 22:31:34 +0800 Subject: [PATCH 1/2] fix(tui): let the prompt Down arrow reach the end of the text The textarea's cursorOffset is measured in display columns, where a newline takes one position and a tab takes two. Both prompt implementations compared it against something else: - packages/tui: input.plainText.length, so any wide character made the end offset too small. The command then decided the cursor was not at the end, reset it to the middle of the text, and the built-in move-down brought it back - the cursor bounced between two positions and never reached the end. - run-mode composer: Bun.stringWidth, which counts newlines as zero, so it was off by one per newline even for ASCII. It also compared visualRow (viewport-relative) against area.height, which made the cursor jump backwards in a scrolled prompt. Use promptOffsetWidth for the offset arithmetic, and gotoBufferEnd / gotoBufferHome where only cursor placement is needed so the widget does the measuring. Add promptOnFirstRow/promptOnLastRow, which add scrollY to visualRow and compare against getTotalVirtualLineCount. Fixes #40161 --- .../src/cli/cmd/run/footer.prompt.tsx | 31 ++- .../opencode/src/cli/cmd/run/prompt.shared.ts | 16 +- .../cli/run/footer.prompt.cursor.test.tsx | 208 ++++++++++++++++++ packages/tui/src/component/prompt/index.tsx | 16 +- packages/tui/src/prompt/display.ts | 18 +- .../tui/test/prompt/display-offset.test.tsx | 119 ++++++++++ 6 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 packages/opencode/test/cli/run/footer.prompt.cursor.test.tsx create mode 100644 packages/tui/test/prompt/display-offset.test.tsx 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..8f2533267591 100644 --- a/packages/tui/src/prompt/display.ts +++ b/packages/tui/src/prompt/display.ts @@ -1,14 +1,28 @@ +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. + width += part.segment === "\n" ? 1 : part.segment === "\t" ? 2 : Bun.stringWidth(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..b71dbb49e06b --- /dev/null +++ b/packages/tui/test/prompt/display-offset.test.tsx @@ -0,0 +1,119 @@ +/** @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( + () => ( + +