fix(app): prompt input quirks
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user