fix(app): prompt input quirks

This commit is contained in:
Adam
2026-02-12 19:58:43 -06:00
parent b525c03d20
commit 7f95cc64c5
5 changed files with 87 additions and 15 deletions

View File

@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit" import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items" import { PromptContextItems } from "./prompt-input/context-items"
@@ -473,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prev = node.previousSibling const prev = node.previousSibling
const next = node.nextSibling const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" return !!prevIsBr && !next
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
} }
if (node.nodeType !== Node.ELEMENT_NODE) return false if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement const el = node as HTMLElement
@@ -496,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.appendChild(createPill(part)) editorRef.appendChild(createPill(part))
} }
} }
const last = editorRef.lastChild
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
editorRef.appendChild(document.createTextNode("\u200B"))
}
} }
createEffect( createEffect(
@@ -729,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
if (last.nodeType !== Node.TEXT_NODE) { if (last.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(last) const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
const next = last.nextSibling
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
if (isBreak && (!next || emptyText)) {
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
if (!next) last.parentNode?.insertBefore(placeholder, null)
placeholder.textContent = "\u200B"
range.setStart(placeholder, 0)
} else {
range.setStartAfter(last)
}
} }
} }
range.collapse(true) range.collapse(true)
@@ -899,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.current() .current()
.map((part) => ("content" in part ? part.content : "")) .map((part) => ("content" in part ? part.content : ""))
.join("") .join("")
const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
const isEmpty = textContent.trim() === "" || textLength <= 1 const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n") const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0 const inHistory = store.historyIndex >= 0
@@ -907,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd) const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart) const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (event.key === "ArrowUp") { if (direction === "up") {
if (!allowUp) return if (!allowUp) return
if (navigateHistory("up")) { if (navigateHistory("up")) {
event.preventDefault() event.preventDefault()

View File

@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom" import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input editor dom", () => { describe("prompt-input editor dom", () => {
test("createTextFragment preserves newlines with br and zero-width placeholders", () => { test("createTextFragment preserves newlines with consecutive br nodes", () => {
const fragment = createTextFragment("foo\n\nbar") const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div") const container = document.createElement("div")
container.appendChild(fragment) container.appendChild(fragment)
expect(container.childNodes.length).toBe(5) expect(container.childNodes.length).toBe(4)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe("foo") expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[2]?.textContent).toBe("\u200B")
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[4]?.textContent).toBe("bar")
}) })
test("length helpers treat breaks as one char and ignore zero-width chars", () => { test("length helpers treat breaks as one char and ignore zero-width chars", () => {
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
container.remove() container.remove()
}) })
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("a"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("b"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 3)
expect(getCursorPosition(container)).toBe(3)
container.remove()
})
}) })

View File

@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
segments.forEach((segment, index) => { segments.forEach((segment, index) => {
if (segment) { if (segment) {
fragment.appendChild(document.createTextNode(segment)) fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
} }
if (index < segments.length - 1) { if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br")) fragment.appendChild(document.createElement("br"))

View File

@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history" import {
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
if (original[1]?.type !== "file") throw new Error("expected file") if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1) expect(original[1].selection?.startLine).toBe(1)
}) })
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
})
}) })

View File

@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100 export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
const position = Math.max(0, Math.min(cursor, text.length))
if (direction === "up") return !text.slice(0, position).includes("\n")
return !text.slice(position).includes("\n")
}
export function clonePromptParts(prompt: Prompt): Prompt { export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => { return prompt.map((part) => {
if (part.type === "text") return { ...part } if (part.type === "text") return { ...part }