Skip to content

Commit a63fbec

Browse files
authored
Merge branch 'dev' into fix/memory-leaks
2 parents a3fcca8 + 92ab421 commit a63fbec

26 files changed

Lines changed: 937 additions & 92 deletions

File tree

.opencode/agent/translator.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
description: Translate content for a specified locale while preserving technical terms
33
mode: subagent
4-
model: opencode/gemini-3-pro
4+
model: opencode/gemini-3.1-pro
55
---
66

77
You are a professional translator and localization specialist.

github/action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ inputs:
3030
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
3131
required: false
3232

33+
variant:
34+
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
35+
required: false
36+
3337
oidc_base_url:
3438
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
3539
required: false
@@ -71,4 +75,5 @@ runs:
7175
PROMPT: ${{ inputs.prompt }}
7276
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
7377
MENTIONS: ${{ inputs.mentions }}
78+
VARIANT: ${{ inputs.variant }}
7479
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}

packages/app/e2e/terminal/terminal-init.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
66
await gotoSession()
77

88
const terminals = page.locator(terminalSelector)
9+
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
910
const opened = await terminals.first().isVisible()
1011

1112
if (!opened) {
@@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
2122
await page.locator(promptSelector).click()
2223
await page.keyboard.press("Control+Alt+T")
2324

24-
await expect(terminals).toHaveCount(2)
25-
await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
25+
await expect(tabs).toHaveCount(2)
26+
await expect(terminals).toHaveCount(1)
27+
await expect(terminals.first().locator("textarea")).toHaveCount(1)
2628
})

packages/app/src/components/prompt-input.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ const EXAMPLES = [
8989
"prompt.example.25",
9090
] as const
9191

92+
const NON_EMPTY_TEXT = /[^\s\u200B]/
93+
9294
export const PromptInput: Component<PromptInputProps> = (props) => {
9395
const sdk = useSDK()
9496
const sync = useSync()
@@ -636,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
636638
let buffer = ""
637639

638640
const flushText = () => {
639-
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
641+
let content = buffer
642+
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
643+
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
640644
buffer = ""
641645
if (!content) return
642646
parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -714,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
714718
const rawParts = parseFromDOM()
715719
const images = imageAttachments()
716720
const cursorPosition = getCursorPosition(editorRef)
717-
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
718-
const trimmed = rawText.replace(/\u200B/g, "").trim()
721+
const rawText =
722+
rawParts.length === 1 && rawParts[0]?.type === "text"
723+
? rawParts[0].content
724+
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
719725
const hasNonText = rawParts.some((part) => part.type !== "text")
720-
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
726+
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
721727

722728
if (shouldReset) {
723729
closePopover()
@@ -757,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
757763
}
758764

759765
const addPart = (part: ContentPart) => {
766+
if (part.type === "image") return false
767+
760768
const selection = window.getSelection()
761-
if (!selection || selection.rangeCount === 0) return
769+
if (!selection) return false
762770

763-
const cursorPosition = getCursorPosition(editorRef)
764-
const currentPrompt = prompt.current()
765-
const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
766-
const textBeforeCursor = rawText.substring(0, cursorPosition)
767-
const atMatch = textBeforeCursor.match(/@(\S*)$/)
771+
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
772+
editorRef.focus()
773+
const cursor = prompt.cursor() ?? promptLength(prompt.current())
774+
setCursorPosition(editorRef, cursor)
775+
}
776+
777+
if (selection.rangeCount === 0) return false
778+
const range = selection.getRangeAt(0)
779+
if (!editorRef.contains(range.startContainer)) return false
768780

769781
if (part.type === "file" || part.type === "agent") {
782+
const cursorPosition = getCursorPosition(editorRef)
783+
const rawText = prompt
784+
.current()
785+
.map((p) => ("content" in p ? p.content : ""))
786+
.join("")
787+
const textBeforeCursor = rawText.substring(0, cursorPosition)
788+
const atMatch = textBeforeCursor.match(/@(\S*)$/)
770789
const pill = createPill(part)
771790
const gap = document.createTextNode(" ")
772-
const range = selection.getRangeAt(0)
773791

774792
if (atMatch) {
775793
const start = atMatch.index ?? cursorPosition - atMatch[0].length
@@ -784,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
784802
range.collapse(true)
785803
selection.removeAllRanges()
786804
selection.addRange(range)
787-
} else if (part.type === "text") {
788-
const range = selection.getRangeAt(0)
805+
}
806+
807+
if (part.type === "text") {
789808
const fragment = createTextFragment(part.content)
790809
const last = fragment.lastChild
791810
range.deleteContents()
@@ -821,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
821840

822841
handleInput()
823842
closePopover()
843+
return true
824844
}
825845

826846
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {

packages/app/src/components/prompt-input/attachments.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,27 @@ import { getCursorPosition } from "./editor-dom"
77

88
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
99
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
10+
const LARGE_PASTE_CHARS = 8000
11+
const LARGE_PASTE_BREAKS = 120
12+
13+
function largePaste(text: string) {
14+
if (text.length >= LARGE_PASTE_CHARS) return true
15+
let breaks = 0
16+
for (const char of text) {
17+
if (char !== "\n") continue
18+
breaks += 1
19+
if (breaks >= LARGE_PASTE_BREAKS) return true
20+
}
21+
return false
22+
}
1023

1124
type PromptAttachmentsInput = {
1225
editor: () => HTMLDivElement | undefined
1326
isFocused: () => boolean
1427
isDialogActive: () => boolean
1528
setDraggingType: (type: "image" | "@mention" | null) => void
1629
focusEditor: () => void
17-
addPart: (part: ContentPart) => void
30+
addPart: (part: ContentPart) => boolean
1831
readClipboardImage?: () => Promise<File | null>
1932
}
2033

@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
89102
}
90103

91104
if (!plainText) return
105+
106+
if (largePaste(plainText)) {
107+
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
108+
input.focusEditor()
109+
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
110+
}
111+
92112
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
93113
if (inserted) return
94114

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
2424
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
2525
})
2626

27+
test("createTextFragment avoids break-node explosion for large multiline content", () => {
28+
const content = Array.from({ length: 220 }, () => "line").join("\n")
29+
const fragment = createTextFragment(content)
30+
const container = document.createElement("div")
31+
container.appendChild(fragment)
32+
33+
expect(container.childNodes.length).toBe(1)
34+
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
35+
expect(container.textContent).toBe(content)
36+
})
37+
38+
test("createTextFragment keeps terminal break in large multiline fallback", () => {
39+
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
40+
const fragment = createTextFragment(content)
41+
const container = document.createElement("div")
42+
container.appendChild(fragment)
43+
44+
expect(container.childNodes.length).toBe(2)
45+
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
46+
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
47+
})
48+
2749
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
2850
const container = document.createElement("div")
2951
container.appendChild(document.createTextNode("ab\u200B"))

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1+
const MAX_BREAKS = 200
2+
13
export function createTextFragment(content: string): DocumentFragment {
24
const fragment = document.createDocumentFragment()
5+
let breaks = 0
6+
for (const char of content) {
7+
if (char !== "\n") continue
8+
breaks += 1
9+
if (breaks > MAX_BREAKS) {
10+
const tail = content.endsWith("\n")
11+
const text = tail ? content.slice(0, -1) : content
12+
if (text) fragment.appendChild(document.createTextNode(text))
13+
if (tail) fragment.appendChild(document.createElement("br"))
14+
return fragment
15+
}
16+
}
17+
318
const segments = content.split("\n")
419
segments.forEach((segment, index) => {
520
if (segment) {

packages/app/src/components/terminal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
540540
disposed = true
541541
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
542542
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
543-
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
543+
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
544544

545545
const finalize = () => {
546546
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })

packages/app/src/pages/session/terminal-panel.tsx

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ export function TerminalPanel() {
6767
on(
6868
() => terminal.active(),
6969
(activeId) => {
70-
if (!activeId || !opened()) return
70+
if (!activeId || !open()) return
7171
if (document.activeElement instanceof HTMLElement) {
7272
document.activeElement.blur()
7373
}
74-
focusTerminalById(activeId)
74+
setTimeout(() => focusTerminalById(activeId), 0)
7575
},
7676
),
7777
)
@@ -209,21 +209,17 @@ export function TerminalPanel() {
209209
</Tabs.List>
210210
</Tabs>
211211
<div class="flex-1 min-h-0 relative">
212-
<For each={all()}>
213-
{(pty) => (
214-
<div
215-
id={`terminal-wrapper-${pty.id}`}
216-
class="absolute inset-0"
217-
style={{
218-
display: terminal.active() === pty.id ? "block" : "none",
219-
}}
220-
>
221-
<Show when={pty.id} keyed>
222-
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
223-
</Show>
224-
</div>
212+
<Show when={terminal.active()} keyed>
213+
{(id) => (
214+
<Show when={byId().get(id)}>
215+
{(pty) => (
216+
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
217+
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
218+
</div>
219+
)}
220+
</Show>
225221
)}
226-
</For>
222+
</Show>
227223
</div>
228224
</div>
229225
<DragOverlay>

packages/desktop/src-tauri/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ pub fn spawn_command(
320320
};
321321

322322
let mut cmd = Command::new(shell);
323-
cmd.args(["-l", "-c", &line]);
323+
cmd.args(["-il", "-c", &line]);
324324

325325
for (key, value) in envs {
326326
cmd.env(key, value);

0 commit comments

Comments
 (0)