Compare commits

..

2 Commits

Author SHA1 Message Date
Shoubhit Dash
32ba9287b6 tui: warn about attachment limits immediately instead of batch processing 2026-03-23 13:00:49 +05:30
Shoubhit Dash
3c8b069bba fix(app): warn on oversized prompt attachments 2026-03-23 12:52:27 +05:30
25 changed files with 276 additions and 296 deletions

View File

@@ -41,7 +41,6 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
@@ -1890,8 +1889,6 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
"x86_64-linux": "sha256-CxeVxNDKEvMsdZpvGZOShklSQ+pWAYq4S3cKDoo6cPQ=",
"aarch64-linux": "sha256-qkMacyXRgbFW9ZvAPepDM5O8GROpXtIZhgQsPOHVogg=",
"aarch64-darwin": "sha256-jPGGoMViHvMBYFqe8BdtWVT1tvPUKYiLCrOy34PjOG0=",
"x86_64-darwin": "sha256-Dss5ChrHZV5/J32iNIh4E+ASltlZsp4QKIiKNlDfAWw="
}
}

View File

@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page, key = "K") {
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+${key}`)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { closeDialog, openPalette } from "../actions"
import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@@ -9,12 +9,3 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})

View File

@@ -108,10 +108,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft)
await wait(page, draft)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
@@ -122,7 +119,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await wait(page, draft)
})
})

View File

@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("K")
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)

View File

@@ -51,7 +51,6 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { MAX_ATTACHMENT_BYTES, estimateAttachment, totalAttachments, wouldExceedAttachmentLimit } from "./limit"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
@@ -42,3 +43,19 @@ describe("pasteMode", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
describe("attachment limit", () => {
test("estimates encoded attachment size", () => {
expect(estimateAttachment({ size: 3 }, "image/png")).toBe("data:image/png;base64,".length + 4)
})
test("totals current attachments", () => {
expect(totalAttachments([{ dataUrl: "abc" }, { dataUrl: "de" }])).toBe(5)
})
test("flags uploads that exceed the total limit", () => {
const list = [{ dataUrl: "a".repeat(MAX_ATTACHMENT_BYTES - 4) }]
expect(wouldExceedAttachmentLimit(list, { size: 3 }, "image/png")).toBe(true)
expect(wouldExceedAttachmentLimit([], { size: 3 }, "image/png")).toBe(false)
})
})

View File

@@ -5,6 +5,7 @@ import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
import { wouldExceedAttachmentLimit } from "./limit"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) {
@@ -33,6 +34,8 @@ type PromptAttachmentsInput = {
readClipboardImage?: () => Promise<File | null>
}
type AddState = "added" | "failed" | "unsupported" | "limit"
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
@@ -44,18 +47,27 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
})
}
const add = async (file: File, toast = true) => {
const warnLimit = () => {
showToast({
title: language.t("prompt.toast.attachmentLimit.title"),
description: language.t("prompt.toast.attachmentLimit.description"),
})
}
const add = async (file: File): Promise<AddState> => {
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
return false
return "unsupported" as const
}
const editor = input.editor()
if (!editor) return false
if (!editor) return "failed" as const
const images = prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image")
if (wouldExceedAttachmentLimit(images, file, mime)) return "limit" as const
const url = await dataUrl(file, mime)
if (!url) return false
if (!url) return "failed" as const
const attachment: ImageAttachmentPart = {
type: "image",
@@ -66,23 +78,26 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true
return "added" as const
}
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
const addAttachments = async (list: File[]) => {
const result = { added: false, unsupported: false }
for (const file of list) {
const state = await add(file)
if (state === "limit") {
warnLimit()
return result.added
}
result.added = result.added || state === "added"
result.unsupported = result.unsupported || state === "unsupported"
}
if (!found && files.length > 0 && toast) warn()
return found
if (!result.added && result.unsupported) warn()
return result.added
}
const addAttachment = (file: File) => addAttachments([file])
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)

View File

@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,14 +135,11 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)

View File

@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 && text.length === 0
if (direction === "up") return position === 0
return position === text.length
}

View File

@@ -0,0 +1,15 @@
const MB = 1024 * 1024
export const MAX_ATTACHMENT_BYTES = 5 * MB
export function estimateAttachment(file: { size: number }, mime: string) {
return `data:${mime};base64,`.length + Math.ceil(file.size / 3) * 4
}
export function totalAttachments(list: Array<{ dataUrl: string }>) {
return list.reduce((sum, part) => sum + part.dataUrl.length, 0)
}
export function wouldExceedAttachmentLimit(list: Array<{ dataUrl: string }>, file: { size: number }, mime: string) {
return totalAttachments(list) + estimateAttachment(file, mime) > MAX_ATTACHMENT_BYTES
}

View File

@@ -40,11 +40,4 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("")
})
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
})

View File

@@ -281,6 +281,8 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.attachmentLimit.title": "Attachment limit reached",
"prompt.toast.attachmentLimit.description": "Attachments can total up to 5 MB. Try smaller or fewer files.",
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",

View File

@@ -211,22 +211,13 @@ export default function Layout(props: ParentProps) {
onMount(() => {
const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
})
@@ -246,12 +237,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
@@ -320,7 +305,8 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
reset()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const navigateWithSidebarReset = (href: string) => {
@@ -1989,10 +1975,6 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,

View File

@@ -157,45 +157,34 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={<div ref={ref}>{props.trigger}</div>}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
}): JSX.Element => (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={props.trigger}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
}
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()

View File

@@ -23,7 +23,6 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void
openSidebar: () => void
closeProject: (directory: string) => void
@@ -198,6 +197,7 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
@@ -264,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
props.ctx.openSidebar()
props.ctx.onHoverOpenChanged(props.project.worktree, false)
props.setOpen(false)
if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree)
}}
@@ -289,16 +289,28 @@ export const SortableProject = (props: {
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({
open: false,
menu: false,
suppressHover: false,
})
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
const active = createMemo(
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
createEffect(() => {
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
@@ -339,7 +351,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)}
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setOpen={(value) => setState("open", value)}
setSuppressHover={(value) => setState("suppressHover", value)}
language={language}
/>
@@ -350,7 +362,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}>
<HoverCard
open={!state.suppressHover && hoverOpen() && !state.menu}
open={!state.suppressHover && state.open && !state.menu}
openDelay={0}
closeDelay={0}
placement="right-start"
@@ -359,7 +371,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => {
if (state.menu) return
if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value)
setState("open", value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
@@ -374,6 +386,7 @@ export const SortableProject = (props: {
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx}
language={language}
/>

View File

@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
@@ -30,7 +30,6 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
type MessageComment = {
path: string
@@ -251,21 +250,38 @@ export function MessageTimeline(props: {
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true)
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
if (working()) return "showing"
if (prev === "showing" || !timeoutDone()) return "hiding"
return "hidden"
const [slot, setSlot] = createStore({
open: false,
show: false,
fade: false,
})
createEffect(() => {
if (workingStatus() !== "hiding") return
setTimeoutDone(false)
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
let f: number | undefined
const clear = () => {
if (f !== undefined) window.clearTimeout(f)
f = undefined
}
onCleanup(clear)
createEffect(
on(
working,
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
@@ -660,15 +676,17 @@ export function MessageTimeline(props: {
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={workingStatus() !== "hidden"}>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div>
@@ -894,6 +912,7 @@ export function MessageTimeline(props: {
</div>
</div>
</Show>
<div
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"

View File

@@ -438,10 +438,12 @@ export function SessionSidePanel(props: {
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>

View File

@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),

View File

@@ -35,7 +35,6 @@ export type CommandEvent =
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
@@ -192,7 +191,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
return { events, child: { kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {

View File

@@ -81,17 +81,6 @@ function setupApp() {
killSidecar()
})
app.on("will-quit", () => {
killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
})
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
@@ -245,15 +234,8 @@ registerIpcHandlers({
function killSidecar() {
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
}
function ensureLoopbackNoProxy() {

View File

@@ -33,16 +33,6 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n")
}
function stats(before: string, after: string) {
let additions = 0
let deletions = 0
for (const change of diffLines(before, after)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
return { additions, deletions }
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@@ -125,16 +115,23 @@ export const EditTool = Tool.define("edit", {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await FileTime.read(ctx.sessionID, filePath)
})
const count = stats(contentOld, contentNew)
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: count.additions,
deletions: count.deletions,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
@@ -170,18 +167,7 @@ export const EditTool = Tool.define("edit", {
},
})
type Prep = {
content: string
find: string
lines: string[]
finds: string[]
}
export type Replacer = (prep: Prep) => Generator<string, void, unknown>
function trimLastEmpty(lines: string[]) {
return lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines
}
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
@@ -195,36 +181,37 @@ function levenshtein(a: string, b: string): number {
if (a === "" || b === "") {
return Math.max(a.length, b.length)
}
const [left, right] = a.length < b.length ? [a, b] : [b, a]
let prev = Array.from({ length: left.length + 1 }, (_, i) => i)
let next = Array.from({ length: left.length + 1 }, () => 0)
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
for (let i = 1; i <= right.length; i++) {
next[0] = i
for (let j = 1; j <= left.length; j++) {
const cost = right[i - 1] === left[j - 1] ? 0 : 1
next[j] = Math.min(next[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
}
;[prev, next] = [next, prev]
}
return matrix[a.length][b.length]
}
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
return prev[left.length]
}
export const SimpleReplacer: Replacer = function* (prep) {
yield prep.find
}
export const LineTrimmedReplacer: Replacer = function* (prep) {
const original = prep.lines
const search = trimLastEmpty(prep.finds)
for (let i = 0; i <= original.length - search.length; i++) {
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < search.length; j++) {
const originalTrimmed = original[i + j].trim()
const searchTrimmed = search[j].trim()
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
@@ -235,42 +222,48 @@ export const LineTrimmedReplacer: Replacer = function* (prep) {
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += original[k].length + 1
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < search.length; k++) {
matchEndIndex += original[i + k].length
if (k < search.length - 1) {
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length
if (k < searchLines.length - 1) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield prep.content.substring(matchStartIndex, matchEndIndex)
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (prep) {
const original = prep.lines
const search = trimLastEmpty(prep.finds)
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (search.length < 3) return
if (searchLines.length < 3) {
return
}
const firstLineSearch = search[0].trim()
const lastLineSearch = search[search.length - 1].trim()
const searchBlockSize = search.length
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
for (let i = 0; i < original.length; i++) {
if (original[i].trim() !== firstLineSearch) {
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < original.length; j++) {
if (original[j].trim() === lastLineSearch) {
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
candidates.push({ startLine: i, endLine: j })
break // Only match the first occurrence of the last line
}
@@ -292,8 +285,8 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = original[startLine + j].trim()
const searchLine = search[j].trim()
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
@@ -314,16 +307,16 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += original[k].length + 1
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += original[k].length
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield prep.content.substring(matchStartIndex, matchEndIndex)
yield content.substring(matchStartIndex, matchEndIndex)
}
return
}
@@ -341,8 +334,8 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = original[startLine + j].trim()
const searchLine = search[j].trim()
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
@@ -367,25 +360,25 @@ export const BlockAnchorReplacer: Replacer = function* (prep) {
const { startLine, endLine } = bestMatch
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += original[k].length + 1
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += original[k].length
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1
}
}
yield prep.content.substring(matchStartIndex, matchEndIndex)
yield content.substring(matchStartIndex, matchEndIndex)
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(prep.find)
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = prep.lines
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
@@ -395,7 +388,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = prep.find.trim().split(/\s+/)
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
@@ -413,7 +406,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
}
// Handle multi-line matches
const findLines = prep.finds
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
@@ -424,7 +417,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
}
}
export const IndentationFlexibleReplacer: Replacer = function* (prep) {
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
@@ -440,9 +433,9 @@ export const IndentationFlexibleReplacer: Replacer = function* (prep) {
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(prep.find)
const contentLines = prep.lines
const findLines = prep.finds
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
@@ -452,7 +445,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (prep) {
}
}
export const EscapeNormalizedReplacer: Replacer = function* (prep) {
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
@@ -480,15 +473,15 @@ export const EscapeNormalizedReplacer: Replacer = function* (prep) {
})
}
const unescapedFind = unescapeString(prep.find)
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (prep.content.includes(unescapedFind)) {
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = prep.lines
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
@@ -501,36 +494,36 @@ export const EscapeNormalizedReplacer: Replacer = function* (prep) {
}
}
export const MultiOccurrenceReplacer: Replacer = function* (prep) {
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = prep.content.indexOf(prep.find, startIndex)
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield prep.find
startIndex = index + prep.find.length
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
const trimmedFind = prep.find.trim()
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === prep.find) {
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (prep.content.includes(trimmedFind)) {
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = prep.lines
const findLines = prep.finds
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
@@ -541,13 +534,19 @@ export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
}
}
export const ContextAwareReplacer: Replacer = function* (prep) {
const findLines = trimLastEmpty(prep.finds)
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
const contentLines = prep.lines
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
@@ -636,13 +635,6 @@ export function replace(content: string, oldString: string, newString: string, r
let notFound = true
const prep = {
content,
find: oldString,
lines: content.split("\n"),
finds: oldString.split("\n"),
} satisfies Prep
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
@@ -654,7 +646,7 @@ export function replace(content: string, oldString: string, newString: string, r
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(prep)) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false

View File

@@ -17,8 +17,6 @@ const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
const MAX_ATTACHMENT_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
@@ -124,9 +122,6 @@ export const ReadTool = Tool.define("read", {
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
if (Number(stat.size) > MAX_ATTACHMENT_BYTES) {
throw new Error(`Cannot attach file larger than ${MAX_ATTACHMENT_LABEL}: ${filepath}`)
}
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
@@ -172,7 +167,7 @@ export const ReadTool = Tool.define("read", {
if (raw.length >= limit) {
hasMoreLines = true
break
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
@@ -203,6 +198,7 @@ export const ReadTool = Tool.define("read", {
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")
const totalLines = lines
const lastReadLine = offset + raw.length - 1
const nextOffset = lastReadLine + 1
const truncated = hasMoreLines || truncatedByBytes
@@ -210,9 +206,9 @@ export const ReadTool = Tool.define("read", {
if (truncatedByBytes) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else if (hasMoreLines) {
output += `\n\n(Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
} else {
output += `\n\n(End of file - total ${lines} lines)`
output += `\n\n(End of file - total ${totalLines} lines)`
}
output += "\n</content>"

View File

@@ -236,7 +236,7 @@ describe("tool.read truncation", () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10. Use offset=11")
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
@@ -418,23 +418,6 @@ describe("tool.read truncation", () => {
})
})
test("rejects oversized image attachments", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "huge.png"), Buffer.alloc(6 * 1024 * 1024, 0))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "huge.png") }, ctx)).rejects.toThrow(
"Cannot attach file larger than 5 MB",
)
},
})
})
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
await using tmp = await tmpdir({
init: async (dir) => {