Skip to content

Commit 05bdc4d

Browse files
committed
fix(tui): let keypad Home and End move the prompt cursor
The session prompt binds `input.buffer.home` and `input.buffer.end` to `none`, so NumPad Home and NumPad End (kitty `CSI 57423 u` / `CSI 57424 u`) do nothing while the caret is in the prompt. Plain Home and End move the caret, which makes the keypad keys look broken rather than unbound. Bind the two commands to `kphome` and `kpend`. These are buffer commands, not line commands: measured on a two-line prompt, plain Home lands at the start of the buffer, so the keypad key has to land there too.
1 parent f36cda5 commit 05bdc4d

3 files changed

Lines changed: 111 additions & 4 deletions

File tree

‎packages/tui/src/config/keybind.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,8 @@ export const Definitions = {
223223
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
224224
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
225225
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
226-
"input.buffer.home": keybind("none", "Move to start of buffer in input"),
227-
"input.buffer.end": keybind("none", "Move to end of buffer in input"),
226+
"input.buffer.home": keybind("kphome", "Move to start of buffer in input"),
227+
"input.buffer.end": keybind("kpend", "Move to end of buffer in input"),
228228
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
229229
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
230230
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),

‎packages/tui/test/config-v2.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ test("preserves current navigation defaults", () => {
167167
expect(config.keybinds.get("session.message.previous")).toEqual([])
168168
expect(config.keybinds.get("session.message.user.next")).toEqual([])
169169
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
170-
expect(config.keybinds.get("input.buffer.home")).toEqual([])
171-
expect(config.keybinds.get("input.buffer.end")).toEqual([])
170+
expect(config.keybinds.get("input.buffer.home")).toMatchObject([{ key: "kphome" }])
171+
expect(config.keybinds.get("input.buffer.end")).toMatchObject([{ key: "kpend" }])
172172
expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
173173
})
174174

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect, test } from "bun:test"
2+
import { ScrollBoxRenderable, TextareaRenderable, type Renderable } from "@opentui/core"
3+
import type { SessionInfo } from "@opencode/client"
4+
import { createAppFixture } from "./fixture/app"
5+
import { directory, json } from "./fixture/tui-client"
6+
import { tmpdir } from "./fixture/fixture"
7+
8+
const LAST = 39
9+
10+
const KP_HOME = "\u001b[57423u"
11+
const KP_END = "\u001b[57424u"
12+
13+
test("keypad Home and End move the prompt cursor", async () => {
14+
await using state = await tmpdir()
15+
await using setup = await fixture(state.path)
16+
const prompt = await ready(setup)
17+
const history = transcript(setup)
18+
19+
await setup.mockInput.typeText("aa")
20+
await setup.mockInput.pressKey("RETURN", { shift: true })
21+
await setup.mockInput.typeText("bb")
22+
await setup.flush()
23+
expect(prompt.plainText).toBe("aa\nbb")
24+
// The taller prompt shortens the transcript viewport, so measure the scroll
25+
// position once the multiline text is in place.
26+
const top = history.scrollTop
27+
28+
await setup.mockInput.pressKeys([KP_HOME])
29+
await setup.mockInput.typeText("x")
30+
await setup.mockInput.pressKeys([KP_END])
31+
await setup.mockInput.typeText("y")
32+
await setup.flush()
33+
34+
expect(prompt.plainText).toBe("xaa\nbby")
35+
expect(history.scrollTop).toBe(top)
36+
})
37+
38+
async function fixture(state: string) {
39+
const session = {
40+
id: `ses_home_end_${crypto.randomUUID()}`,
41+
title: "Home and End fixture",
42+
projectID: "proj_home_end",
43+
location: { directory },
44+
agent: "build",
45+
model: { providerID: "fixture", id: "model" },
46+
cost: 0,
47+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
48+
time: { created: 1, updated: LAST },
49+
} satisfies SessionInfo
50+
const location = { directory, project: { id: session.projectID, directory, canonical: directory } }
51+
const setup = await createAppFixture({
52+
width: 100,
53+
height: 30,
54+
state,
55+
args: { sessionID: session.id },
56+
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
57+
fetch: async (url) => {
58+
if (url.pathname === "/api/location") return json(location)
59+
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
60+
if (url.pathname === "/api/agent")
61+
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
62+
if (url.pathname === "/api/model")
63+
return json({ location, data: [{ id: "model", providerID: "fixture", name: "Model", variants: [] }] })
64+
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "fixture", name: "Fixture" }] })
65+
if (url.pathname === "/api/vcs")
66+
return json({ location, data: { branch: { current: "fixture", default: "dev" } } })
67+
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
68+
if (url.pathname === `/api/session/${session.id}/message`)
69+
return json({
70+
data: Array.from({ length: LAST + 1 }, (_, index) => ({
71+
id: messageID(index),
72+
type: "user",
73+
text: `Transcript item ${String(index).padStart(2, "0")}`,
74+
time: { created: index + 1 },
75+
})).toReversed(),
76+
cursor: {},
77+
})
78+
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
79+
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
80+
if (url.pathname === `/api/session/${session.id}/form`) return json({ data: [] })
81+
if (url.pathname === `/api/session/${session.id}/prompt`) return json({ data: {} })
82+
return undefined
83+
},
84+
})
85+
return setup
86+
}
87+
88+
async function ready(setup: Awaited<ReturnType<typeof fixture>>) {
89+
await setup.ready
90+
await setup.waitForFrame((frame) => frame.includes(`Transcript item ${LAST}`) && frame.includes("Build · Model"), {
91+
maxPasses: 120,
92+
})
93+
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
94+
return setup.renderer.currentFocusedEditor as TextareaRenderable
95+
}
96+
97+
function messageID(index: number) {
98+
return `msg_home_${String(index).padStart(2, "0")}`
99+
}
100+
101+
function transcript(setup: Awaited<ReturnType<typeof fixture>>) {
102+
let item: Renderable | null | undefined = setup.renderer.root.findDescendantById(messageID(LAST))
103+
while (item && !(item instanceof ScrollBoxRenderable)) item = item.parent
104+
if (!(item instanceof ScrollBoxRenderable))
105+
throw new Error(`Missing transcript scrollbox\n${setup.captureCharFrame()}`)
106+
return item
107+
}

0 commit comments

Comments
 (0)