summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-26 20:47:13 -0600
committerAdam <[email protected]>2025-12-26 20:47:13 -0600
commit4385fa4dd79955cdb1d7086365ee1a238ebf9748 (patch)
tree1809f39a2a00d8040fe1cd8b073a03d54cb2922a /packages
parent2b054bec9582b6a6ba421d5ea40576878f8e59e8 (diff)
downloadopencode-4385fa4dd79955cdb1d7086365ee1a238ebf9748.tar.gz
opencode-4385fa4dd79955cdb1d7086365ee1a238ebf9748.zip
fix(desktop): prompt input fixes, directory and branch in status bar
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/prompt-input.tsx161
-rw-r--r--packages/app/src/components/status-bar.tsx26
-rw-r--r--packages/app/src/context/global-sync.tsx8
3 files changed, 176 insertions, 19 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 03fa02fe3..2407fe97a 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -82,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const command = useCommand()
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
+ let scrollRef!: HTMLDivElement
+
+ const scrollCursorIntoView = () => {
+ const container = scrollRef
+ const selection = window.getSelection()
+ if (!container || !selection || selection.rangeCount === 0) return
+
+ const range = selection.getRangeAt(0)
+ if (!editorRef.contains(range.startContainer)) return
+
+ const rect = range.getBoundingClientRect()
+ if (!rect.height) return
+
+ const containerRect = container.getBoundingClientRect()
+ const top = rect.top - containerRect.top + container.scrollTop
+ const bottom = rect.bottom - containerRect.top + container.scrollTop
+ const padding = 12
+
+ if (top < container.scrollTop + padding) {
+ container.scrollTop = Math.max(0, top - padding)
+ return
+ }
+
+ if (bottom > container.scrollTop + container.clientHeight - padding) {
+ container.scrollTop = bottom - container.clientHeight + padding
+ }
+ }
+
+ const queueScroll = () => {
+ requestAnimationFrame(scrollCursorIntoView)
+ }
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -153,6 +184,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.focus()
setCursorPosition(editorRef, length)
setStore("applyingHistory", false)
+ queueScroll()
})
}
@@ -357,9 +389,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(currentParts) => {
const domParts = parseFromDOM()
const normalized = Array.from(editorRef.childNodes).every((node) => {
- if (node.nodeType === Node.TEXT_NODE) return true
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text = node.textContent ?? ""
+ if (!text.includes("\u200B")) return true
+ if (text !== "\u200B") return false
+
+ 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
+ }
if (node.nodeType !== Node.ELEMENT_NODE) return false
- return (node as HTMLElement).dataset.type === "file"
+ const el = node as HTMLElement
+ if (el.dataset.type === "file") return true
+ return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -372,7 +418,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.innerHTML = ""
currentParts.forEach((part) => {
if (part.type === "text") {
- editorRef.appendChild(document.createTextNode(part.content))
+ editorRef.appendChild(createTextFragment(part.content))
} else if (part.type === "file") {
const pill = document.createElement("span")
pill.textContent = part.content
@@ -398,7 +444,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let buffer = ""
const flushText = () => {
- const content = buffer.replace(/\r\n?/g, "\n")
+ const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
buffer = ""
if (!content) return
parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -472,6 +518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (prompt.dirty()) {
prompt.set(DEFAULT_PROMPT, 0)
}
+ queueScroll()
return
}
@@ -500,6 +547,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
prompt.set(rawParts, cursorPosition)
+ queueScroll()
}
const addPart = (part: ContentPart) => {
@@ -529,9 +577,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
- const length = node.textContent?.length ?? 0
+ const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
@@ -539,7 +588,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
- if (isFile && remaining <= length) {
+ if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
@@ -565,11 +614,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "text") {
- const textNode = document.createTextNode(part.content)
const range = selection.getRangeAt(0)
+ const fragment = createTextFragment(part.content)
+ const last = fragment.lastChild
range.deleteContents()
- range.insertNode(textNode)
- range.setStartAfter(textNode)
+ range.insertNode(fragment)
+ if (last) {
+ if (last.nodeType === Node.TEXT_NODE) {
+ const text = last.textContent ?? ""
+ if (text === "\u200B") {
+ range.setStart(last, 0)
+ }
+ if (text !== "\u200B") {
+ range.setStart(last, text.length)
+ }
+ }
+ if (last.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(last)
+ }
+ }
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
@@ -646,6 +709,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Backspace") {
+ const selection = window.getSelection()
+ if (selection && selection.isCollapsed) {
+ const node = selection.anchorNode
+ const offset = selection.anchorOffset
+ if (node && node.nodeType === Node.TEXT_NODE) {
+ const text = node.textContent ?? ""
+ if (/^\u200B+$/.test(text) && offset > 0) {
+ const range = document.createRange()
+ range.setStart(node, 0)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ }
+ }
+ }
+ }
+
if (event.key === "!" && store.mode === "normal") {
const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) {
@@ -686,7 +767,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
- const textContent = editorRef.textContent ?? ""
+ const textContent = prompt
+ .current()
+ .map((part) => ("content" in part ? part.content : ""))
+ .join("")
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
@@ -978,7 +1062,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For>
</div>
</Show>
- <div class="relative max-h-[240px] overflow-y-auto">
+ <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
@@ -1119,23 +1203,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}
+function createTextFragment(content: string): DocumentFragment {
+ const fragment = document.createDocumentFragment()
+ const segments = content.split("\n")
+ 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"))
+ }
+ })
+ return fragment
+}
+
+function getNodeLength(node: Node): number {
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+function getTextLength(node: Node): number {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ let length = 0
+ for (const child of Array.from(node.childNodes)) {
+ length += getTextLength(child)
+ }
+ return length
+}
+
function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
+ if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset)
- return preCaretRange.toString().length
+ return getTextLength(preCaretRange.cloneContents())
}
function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position
let node = parent.firstChild
while (node) {
- const length = node.textContent ? node.textContent.length : 0
+ const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
const range = document.createRange()
@@ -1147,10 +1264,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return
}
- if (isFile && remaining <= length) {
+ if ((isFile || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
- range.setStartAfter(node)
+ if (remaining === 0) {
+ range.setStartBefore(node)
+ }
+ if (remaining > 0 && isFile) {
+ range.setStartAfter(node)
+ }
+ if (remaining > 0 && isBreak) {
+ const next = node.nextSibling
+ if (next && next.nodeType === Node.TEXT_NODE) {
+ range.setStart(next, 0)
+ }
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(node)
+ }
+ }
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx
index e0e25c60b..d8a88503f 100644
--- a/packages/app/src/components/status-bar.tsx
+++ b/packages/app/src/components/status-bar.tsx
@@ -1,13 +1,31 @@
-import { Show, type ParentProps } from "solid-js"
+import { createMemo, Show, type ParentProps } from "solid-js"
import { usePlatform } from "@/context/platform"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
export function StatusBar(props: ParentProps) {
const platform = usePlatform()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+
+ const directoryDisplay = createMemo(() => {
+ const directory = sync.data.path.directory || ""
+ const home = globalSync.data.path.home || ""
+ const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
+ const branch = sync.data.vcs?.branch
+ return branch ? `${short}:${branch}` : short
+ })
+
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
- <Show when={platform.version}>
- <span class="text-12-regular text-text-weak">v{platform.version}</span>
- </Show>
+ <div class="flex items-center gap-3">
+ <Show when={platform.version}>
+ <span class="text-12-regular text-text-weak">v{platform.version}</span>
+ </Show>
+ <Show when={directoryDisplay()}>
+ <span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
+ </Show>
+ </div>
<div class="flex items-center">{props.children}</div>
</div>
)
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 7a9dc8dc4..c51901eb2 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -14,6 +14,7 @@ import {
type Command,
type McpStatus,
type LspStatus,
+ type VcsInfo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -47,6 +48,7 @@ type State = {
[name: string]: McpStatus
}
lsp: LspStatus[]
+ vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
@@ -93,6 +95,7 @@ function createGlobalSync() {
todo: {},
mcp: {},
lsp: [],
+ vcs: undefined,
limit: 5,
message: {},
part: {},
@@ -159,6 +162,7 @@ function createGlobalSync() {
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
+ vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -305,6 +309,10 @@ function createGlobalSync() {
}
break
}
+ case "vcs.branch.updated": {
+ setStore("vcs", { branch: event.properties.branch })
+ break
+ }
}
})