Adam 2 месяцев назад
Родитель
Сommit
7f95cc64c5

+ 26 - 7
packages/app/src/components/prompt-input.tsx

@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 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 { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
 import { PromptContextItems } from "./prompt-input/context-items"
@@ -473,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         const prev = node.previousSibling
         const next = node.nextSibling
         const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
-        const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
-        if (!prevIsBr && !nextIsBr) return false
-        if (nextIsBr && !prevIsBr && prev) return false
-        return true
+        return !!prevIsBr && !next
       }
       if (node.nodeType !== Node.ELEMENT_NODE) return false
       const el = node as HTMLElement
@@ -496,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         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(
@@ -729,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           }
         }
         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)
@@ -899,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         .current()
         .map((part) => ("content" in part ? part.content : ""))
         .join("")
+      const direction = event.key === "ArrowUp" ? "up" : "down"
+      if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
       const isEmpty = textContent.trim() === "" || textLength <= 1
       const hasNewlines = textContent.includes("\n")
       const inHistory = store.historyIndex >= 0
@@ -907,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
       const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
 
-      if (event.key === "ArrowUp") {
+      if (direction === "up") {
         if (!allowUp) return
         if (navigateHistory("up")) {
           event.preventDefault()

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

@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
 import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./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 container = document.createElement("div")
     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[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", () => {
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
 
     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()
+  })
 })

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

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

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

@@ -1,6 +1,12 @@
 import { describe, expect, test } from "bun:test"
 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 }]
 
@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
     if (original[1]?.type !== "file") throw new Error("expected file")
     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)
+  })
 })

+ 7 - 0
packages/app/src/components/prompt-input/history.ts

@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
 
 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 {
   return prompt.map((part) => {
     if (part.type === "text") return { ...part }