diff --git a/bun.lock b/bun.lock index 12835ca39f..edce5832e2 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -215,7 +215,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -244,7 +244,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -260,7 +260,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.63", + "version": "1.1.64", "bin": { "opencode": "./bin/opencode", }, @@ -369,7 +369,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -389,7 +389,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.63", + "version": "1.1.64", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -400,7 +400,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -413,7 +413,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -455,7 +455,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "zod": "catalog:", }, @@ -466,7 +466,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -525,7 +525,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.5", + "@types/bun": "1.3.9", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", @@ -1872,7 +1872,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2206,7 +2206,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/install b/install index 22b7ca39ed..b0716d5320 100755 --- a/install +++ b/install @@ -130,7 +130,7 @@ else needs_baseline=false if [ "$arch" = "x64" ]; then if [ "$os" = "linux" ]; then - if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then + if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then needs_baseline=true fi fi @@ -141,6 +141,20 @@ else needs_baseline=true fi fi + + if [ "$os" = "windows" ]; then + ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)" + out="" + if command -v powershell.exe >/dev/null 2>&1; then + out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true) + elif command -v pwsh >/dev/null 2>&1; then + out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true) + fi + out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') + if [ "$out" != "true" ] && [ "$out" != "1" ]; then + needs_baseline=true + fi + fi fi target="$os-$arch" diff --git a/nix/hashes.json b/nix/hashes.json index a1b41e2f9a..70d7378493 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-saYZlUTkBfg9vp5J1CrJUM1PBXK4xKwyz28RKlT0JWo=", - "aarch64-linux": "sha256-qoiX2CpOD+HSI+eLh3I84TTPdhWdG6MzfkDAXE6ldPo=", - "aarch64-darwin": "sha256-LbAvdaOBuftBoHvQPFwJGr0smg8vH4wNHS6BYdyXdDs=", - "x86_64-darwin": "sha256-bv5qb9Fi8SyrgZFhcdlvYNc4bjyvdyHY3YgUpmkEH2U=" + "x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=", + "aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=", + "aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=", + "x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo=" } } diff --git a/package.json b/package.json index 96bb58d1f2..c4408e264b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.9", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", @@ -23,7 +23,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.5", + "@types/bun": "1.3.9", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 842433891e..52c9007ea1 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' +export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]' export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' +export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]' export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' +export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 42534968b2..9fbcf79f5e 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -9,6 +9,7 @@ import { settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, settingsSoundsAgentSelector, + settingsSoundsAgentEnabledSelector, settingsSoundsErrorsSelector, settingsSoundsPermissionsSelector, settingsThemeSelector, @@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g expect(stored?.sounds?.agent).not.toBe("staplebops-01") }) +test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const select = dialog.locator(settingsSoundsAgentSelector) + const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector) + const trigger = select.locator('[data-slot="select-select-trigger"]') + await expect(select).toBeVisible() + await expect(switchContainer).toBeVisible() + await expect(trigger).toBeEnabled() + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + await expect(trigger).toBeDisabled() + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.sounds?.agentEnabled).toBe(false) +}) + test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/package.json b/packages/app/package.json index 90b5a9c300..ebd1a4b35b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.63", + "version": "1.1.64", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4d24b23158..90f4f41f7c 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -103,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) { return value.label ?? "" } + function formatError(value: unknown, fallback: string): string { + if (value && typeof value === "object" && "data" in value) { + const data = (value as { data?: { message?: unknown } }).data + if (typeof data?.message === "string" && data.message) return data.message + } + if (value && typeof value === "object" && "error" in value) { + const nested = formatError((value as { error?: unknown }).error, "") + if (nested) return nested + } + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message + if (typeof message === "string" && message) return message + } + if (value instanceof Error && value.message) return value.message + if (typeof value === "string" && value) return value + return fallback + } + async function selectMethod(index: number) { if (timer.current !== undefined) { clearTimeout(timer.current) @@ -141,7 +159,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) .catch((e) => { if (!alive.value) return - dispatch({ type: "auth.error", error: String(e) }) + dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) }) } } @@ -328,8 +346,7 @@ export function DialogConnectProvider(props: { provider: string }) { await complete() return } - const message = result.error instanceof Error ? result.error.message : String(result.error) - setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) + setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } return ( @@ -385,7 +402,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (!alive.value) return if (!result.ok) { - const message = result.error instanceof Error ? result.error.message : String(result.error) + const message = formatError(result.error, language.t("common.requestFailed")) dispatch({ type: "auth.error", error: message }) return } diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index d4d4af0f10..ace79e38a7 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -1,6 +1,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { Button } from "@opencode-ai/ui/button" import type { Component } from "solid-js" import { useLocal } from "@/context/local" @@ -18,6 +19,14 @@ export const DialogManageModels: Component = () => { dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) + const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) + const providerVisible = (providerID: string) => + providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) + const setProviderVisibility = (providerID: string, checked: boolean) => { + providerList(providerID).forEach((x) => { + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) + }) + } return ( { items={local.model.list()} filterKeys={["provider.name", "name", "id"]} sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} + groupBy={(x) => x.provider.id} + groupHeader={(group) => { + const provider = group.items[0].provider + return ( + <> + {provider.name} + + setProviderVisibility(provider.id, checked)} + hideLabel + > + {provider.name} + + + + ) + }} sortGroupsBy={(a, b) => { const aRank = providerRank(a.items[0].provider.id) const bRank = providerRank(b.items[0].provider.id) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index f35d0564ce..29a3666c03 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -347,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil tabs().open(value) file.load(path) if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.open() layout.fileTree.setTab("all") props.onOpenFile?.(path) + tabs().setActive(value) } const handleSelect = (item: Entry | undefined) => { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 146f1b64e4..8e8c3c895b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/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" @@ -158,14 +163,13 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.open() layout.fileTree.setTab("changes") + tabs().setActive("review") requestAnimationFrame(() => comments.setFocus(focus)) return } if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) tabs().open(tab) @@ -474,10 +478,7 @@ export const PromptInput: Component = (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 @@ -497,6 +498,11 @@ export const PromptInput: Component = (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( @@ -730,7 +736,17 @@ export const PromptInput: Component = (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) @@ -900,6 +916,8 @@ export const PromptInput: Component = (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 @@ -908,7 +926,7 @@ export const PromptInput: Component = (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() diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts index fce8b4b953..15e759f44a 100644 --- a/packages/app/src/components/prompt-input/editor-dom.test.ts +++ b/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() + }) }) diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts index 3116ceb126..4850a26ece 100644 --- a/packages/app/src/components/prompt-input/editor-dom.ts +++ b/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")) diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 54be9cb75b..a37fdad677 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/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) + }) }) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 63164f0ba3..f26f808487 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/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 } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 554a15bb78..259883d61e 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -53,18 +53,15 @@ export const PromptPopover: Component = (props) => { > {(item) => { - const active = props.atActive === props.atKey(item) - const shared = { - "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, - "bg-surface-raised-base-hover": active, - } + const key = props.atKey(item) if (item.type === "agent") { return (