Skip to content

Commit 7f95cc6

Browse files
committed
fix(app): prompt input quirks
1 parent b525c03 commit 7f95cc6

5 files changed

Lines changed: 87 additions & 15 deletions

File tree

packages/app/src/components/prompt-input.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
3838
import { usePlatform } from "@/context/platform"
3939
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
4040
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
41-
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
41+
import {
42+
canNavigateHistoryAtCursor,
43+
navigatePromptHistory,
44+
prependHistoryEntry,
45+
promptLength,
46+
} from "./prompt-input/history"
4247
import { createPromptSubmit } from "./prompt-input/submit"
4348
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
4449
import { PromptContextItems } from "./prompt-input/context-items"
@@ -473,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
473478
const prev = node.previousSibling
474479
const next = node.nextSibling
475480
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
476-
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
477-
if (!prevIsBr && !nextIsBr) return false
478-
if (nextIsBr && !prevIsBr && prev) return false
479-
return true
481+
return !!prevIsBr && !next
480482
}
481483
if (node.nodeType !== Node.ELEMENT_NODE) return false
482484
const el = node as HTMLElement
@@ -496,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
496498
editorRef.appendChild(createPill(part))
497499
}
498500
}
501+
502+
const last = editorRef.lastChild
503+
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
504+
editorRef.appendChild(document.createTextNode("\u200B"))
505+
}
499506
}
500507

501508
createEffect(
@@ -729,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
729736
}
730737
}
731738
if (last.nodeType !== Node.TEXT_NODE) {
732-
range.setStartAfter(last)
739+
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
740+
const next = last.nextSibling
741+
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
742+
if (isBreak && (!next || emptyText)) {
743+
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
744+
if (!next) last.parentNode?.insertBefore(placeholder, null)
745+
placeholder.textContent = "\u200B"
746+
range.setStart(placeholder, 0)
747+
} else {
748+
range.setStartAfter(last)
749+
}
733750
}
734751
}
735752
range.collapse(true)
@@ -899,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
899916
.current()
900917
.map((part) => ("content" in part ? part.content : ""))
901918
.join("")
919+
const direction = event.key === "ArrowUp" ? "up" : "down"
920+
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
902921
const isEmpty = textContent.trim() === "" || textLength <= 1
903922
const hasNewlines = textContent.includes("\n")
904923
const inHistory = store.historyIndex >= 0
@@ -907,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
907926
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
908927
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
909928

910-
if (event.key === "ArrowUp") {
929+
if (direction === "up") {
911930
if (!allowUp) return
912931
if (navigateHistory("up")) {
913932
event.preventDefault()

packages/app/src/components/prompt-input/editor-dom.test.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
22
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
33

44
describe("prompt-input editor dom", () => {
5-
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
5+
test("createTextFragment preserves newlines with consecutive br nodes", () => {
66
const fragment = createTextFragment("foo\n\nbar")
77
const container = document.createElement("div")
88
container.appendChild(fragment)
99

10-
expect(container.childNodes.length).toBe(5)
10+
expect(container.childNodes.length).toBe(4)
11+
expect(container.childNodes[0]?.textContent).toBe("foo")
12+
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
13+
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
14+
expect(container.childNodes[3]?.textContent).toBe("bar")
15+
})
16+
17+
test("createTextFragment keeps trailing newline as terminal break", () => {
18+
const fragment = createTextFragment("foo\n")
19+
const container = document.createElement("div")
20+
container.appendChild(fragment)
21+
22+
expect(container.childNodes.length).toBe(2)
1123
expect(container.childNodes[0]?.textContent).toBe("foo")
1224
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
13-
expect(container.childNodes[2]?.textContent).toBe("\u200B")
14-
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
15-
expect(container.childNodes[4]?.textContent).toBe("bar")
1625
})
1726

1827
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
4857

4958
container.remove()
5059
})
60+
61+
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
62+
const container = document.createElement("div")
63+
container.appendChild(document.createTextNode("a"))
64+
container.appendChild(document.createElement("br"))
65+
container.appendChild(document.createElement("br"))
66+
container.appendChild(document.createTextNode("b"))
67+
document.body.appendChild(container)
68+
69+
setCursorPosition(container, 2)
70+
expect(getCursorPosition(container)).toBe(2)
71+
72+
setCursorPosition(container, 3)
73+
expect(getCursorPosition(container)).toBe(3)
74+
75+
container.remove()
76+
})
5177
})

packages/app/src/components/prompt-input/editor-dom.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
44
segments.forEach((segment, index) => {
55
if (segment) {
66
fragment.appendChild(document.createTextNode(segment))
7-
} else if (segments.length > 1) {
8-
fragment.appendChild(document.createTextNode("\u200B"))
97
}
108
if (index < segments.length - 1) {
119
fragment.appendChild(document.createElement("br"))

packages/app/src/components/prompt-input/history.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, test } from "bun:test"
22
import type { Prompt } from "@/context/prompt"
3-
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
3+
import {
4+
canNavigateHistoryAtCursor,
5+
clonePromptParts,
6+
navigatePromptHistory,
7+
prependHistoryEntry,
8+
promptLength,
9+
} from "./history"
410

511
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
612

@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
6672
if (original[1]?.type !== "file") throw new Error("expected file")
6773
expect(original[1].selection?.startLine).toBe(1)
6874
})
75+
76+
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
77+
const value = "a\nb\nc"
78+
79+
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
80+
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
81+
82+
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
83+
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
84+
85+
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
86+
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
87+
88+
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
89+
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
90+
})
6991
})

packages/app/src/components/prompt-input/history.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
44

55
export const MAX_HISTORY = 100
66

7+
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
8+
if (!text.includes("\n")) return true
9+
const position = Math.max(0, Math.min(cursor, text.length))
10+
if (direction === "up") return !text.slice(0, position).includes("\n")
11+
return !text.slice(position).includes("\n")
12+
}
13+
714
export function clonePromptParts(prompt: Prompt): Prompt {
815
return prompt.map((part) => {
916
if (part.type === "text") return { ...part }

0 commit comments

Comments
 (0)