Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()) {
Expand All @@ -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 = {
Expand Down
16 changes: 12 additions & 4 deletions packages/opencode/src/cli/cmd/run/prompt.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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,
}
}
208 changes: 208 additions & 0 deletions packages/opencode/test/cli/run/footer.prompt.cursor.test.tsx
Original file line number Diff line number Diff line change
@@ -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<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
const [state] = createSignal<FooterState>({
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 (
<OpencodeKeymapProvider keymap={keymap}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
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}
/>
</OpencodeKeymapProvider>
)
}

const app = await testRender(
() => (
<box width={40} height={16}>
<Harness />
</box>
),
{ 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()
}
})
16 changes: 6 additions & 10 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -508,7 +508,7 @@ export function Prompt(props: PromptProps) {
parts: updatedNonTextParts,
})
restoreExtmarksFromParts(updatedNonTextParts)
input.cursorOffset = Bun.stringWidth(normalized)
input.gotoBufferEnd()
},
},
{
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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()
},
},
],
Expand Down
Loading
Loading