From 7bae5e910e5d0896f06c8d8fe5b1392e3663acc3 Mon Sep 17 00:00:00 2001 From: mounir Haddou Date: Mon, 6 Apr 2026 00:40:09 +0100 Subject: [PATCH 1/5] Differentiate paste summaries with numbering and color styles Improve how pasted snippets are represented in the prompt UI so each paste block is easier to identify at a glance. - Add numbered paste summaries, producing labels like [Pasted 1 ~8 lines]. - Handle singular/plural line labels for cleaner copy. - Introduce deterministic per-paste colors derived from paste index. - Register dynamic styles and apply them to pasted text extmarks. When multiple snippets are pasted, identical markers are hard to distinguish. Numbering and stable color coding make each block visually traceable without expanding it. --- .../cli/cmd/tui/component/prompt/index.tsx | 90 ++++++++++++++++++- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5123cea56754..0676a91733eb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,15 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core" +import { + BoxRenderable, + TextareaRenderable, + MouseEvent, + PasteEvent, + decodePasteBytes, + t, + dim, + fg, + RGBA, + hsvToRgb, +} from "@opentui/core" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" @@ -121,6 +132,51 @@ export function Prompt(props: PromptProps) { const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! + + function pasteNumber(value: string) { + const match = value.match(/^\[Pasted\s+(\d+)\s+~/) + const n = Number(match?.[1]) + if (!Number.isFinite(n) || n <= 0) { + return + } + + return n + } + + function pasteColor(n: number) { + const idx = n - 1 + const h = (idx * 137.508) % 360 + const s = Math.min(0.55 + (idx % 5) * 0.08, 0.9) + const v = Math.min(0.72 + (Math.floor(idx / 5) % 4) * 0.07, 0.95) + return hsvToRgb(h, s, v) + } + + function pasteStyle(value: string) { + const n = pasteNumber(value) + if (!n) { + return pasteStyleId + } + + const name = `extmark.paste.${n}` + const id = syntax().getStyleId(name) + if (id !== null) { + return id + } + + const bg = pasteColor(n) + const [r, g, b] = bg.toInts() + let text = RGBA.fromInts(255, 255, 255) + if (r * 0.299 + g * 0.587 + b * 0.114 > 150) { + text = RGBA.fromInts(0, 0, 0) + } + + return syntax().registerStyle(name, { + fg: text, + bg, + bold: true, + }) + } + let promptPartTypeId = 0 sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { @@ -496,7 +552,7 @@ export function Prompt(props: PromptProps) { start = part.source.text.start end = part.source.text.end virtualText = part.source.text.value - styleId = pasteStyleId + styleId = pasteStyle(virtualText) } if (virtualText) { @@ -755,6 +811,7 @@ export function Prompt(props: PromptProps) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const extmarkEnd = extmarkStart + virtualText.length + const styleId = pasteStyle(virtualText) input.insertText(virtualText + " ") @@ -762,7 +819,7 @@ export function Prompt(props: PromptProps) { start: extmarkStart, end: extmarkEnd, virtual: true, - styleId: pasteStyleId, + styleId, typeId: promptPartTypeId, }) @@ -1065,8 +1122,33 @@ export function Prompt(props: PromptProps) { (lineCount >= 3 || pastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary ) { + const lineLabel = lineCount === 1 ? "line" : "lines" + const pasteCount = + store.prompt.parts.reduce((max, part) => { + if (part.type !== "text") { + return max + } + + if (!part.source || !("text" in part.source)) { + return max + } + + const value = part.source.text.value + if (!value.startsWith("[Pasted ")) { + return max + } + + const match = value.match(/^\[Pasted\s+(\d+)\s+~/) + const n = Number(match?.[1]) + if (!Number.isFinite(n) || n <= 0) { + return max + } + + return Math.max(max, n) + }, 0) + 1 + event.preventDefault() - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + pasteText(pastedContent, `[Pasted ${pasteCount} ~${lineCount} ${lineLabel}]`) return } From 00094a9d471b4acb1888fd2d00c488f0c1fc684e Mon Sep 17 00:00:00 2001 From: mounir Haddou Date: Mon, 6 Apr 2026 00:42:11 +0100 Subject: [PATCH 2/5] Add paste expand/collapse toggle and robust submit expansion Make pasted summary markers interactive and ensure expanded content is submitted with correct part mapping. - Add mouse-up toggle support to expand or collapse pasted summary extmarks in place. - Preserve cursor and viewport stability while toggling. - Expand text parts through a dedicated submit path and rebuild part offsets from the expanded input. - Update extmark restoration and sync flows so edited expanded text is persisted back to prompt parts. This keeps prompts compact by default while allowing quick inspection/editing of pasted content without breaking submission correctness. --- .../cli/cmd/tui/component/prompt/index.tsx | 331 ++++++++++++++++-- 1 file changed, 308 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 0676a91733eb..dd8438d73e54 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -177,6 +177,26 @@ export function Prompt(props: PromptProps) { }) } + function pasteTextStyle(value: string) { + const n = pasteNumber(value) + if (!n) { + return pasteStyleId + } + + const name = `extmark.paste.text.${n}` + const id = syntax().getStyleId(name) + if (id !== null) { + return id + } + + const fg = pasteColor(n) + + return syntax().registerStyle(name, { + fg, + bold: true, + }) + } + let promptPartTypeId = 0 sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { @@ -537,29 +557,40 @@ export function Prompt(props: PromptProps) { let end = 0 let virtualText = "" let styleId: number | undefined + let virtual = true if (part.type === "file" && part.source?.text) { start = part.source.text.start end = part.source.text.end virtualText = part.source.text.value styleId = fileStyleId - } else if (part.type === "agent" && part.source) { + } + + if (part.type === "agent" && part.source) { start = part.source.start end = part.source.end virtualText = part.source.value styleId = agentStyleId - } else if (part.type === "text" && part.source?.text) { + } + + if (part.type === "text" && part.source?.text) { start = part.source.text.start end = part.source.text.end virtualText = part.source.text.value - styleId = pasteStyle(virtualText) + const current = input.plainText.slice(start, end) + const collapsed = current === virtualText + virtual = collapsed + styleId = pasteTextStyle(virtualText) + if (collapsed) { + styleId = pasteStyle(virtualText) + } } if (virtualText) { const extmarkId = input.extmarks.create({ start, end, - virtual: true, + virtual, styleId, typeId: promptPartTypeId, }) @@ -593,6 +624,10 @@ export function Prompt(props: PromptProps) { } else if (part.type === "text" && part.source?.text) { part.source.text.start = extmark.start part.source.text.end = extmark.end + const value = draft.prompt.input.slice(extmark.start, extmark.end) + if (value !== part.source.text.value) { + part.text = value + } } newMap.set(extmark.id, newParts.length) newParts.push(part) @@ -606,6 +641,270 @@ export function Prompt(props: PromptProps) { ) } + function expandText(offset?: number) { + let value = store.prompt.input + let cursor = offset + + const sorted = input.extmarks + .getAllForTypeId(promptPartTypeId) + .sort((a: { start: number }, b: { start: number }) => b.start - a.start) + + for (const extmark of sorted) { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + if (partIndex === undefined) continue + + const part = store.prompt.parts[partIndex] + if (part?.type !== "text") { + continue + } + + if (!part.text) { + continue + } + + const before = value.slice(0, extmark.start) + const after = value.slice(extmark.end) + value = before + part.text + after + + if (cursor === undefined) { + continue + } + + const delta = part.text.length - (extmark.end - extmark.start) + if (cursor > extmark.end) { + cursor += delta + continue + } + if (cursor >= extmark.start) { + cursor = extmark.start + part.text.length + } + } + + const parts = store.prompt.parts + .filter((part) => part.type !== "text") + .map((part) => { + if (part.type === "file" && part.source?.text) { + return { + part, + value: part.source.text.value, + start: part.source.text.start, + } + } + + if (part.type === "agent" && part.source) { + return { + part, + value: part.source.value, + start: part.source.start, + } + } + + return { + part, + value: "", + start: Number.MAX_SAFE_INTEGER, + } + }) + .sort((a, b) => a.start - b.start) + .reduce( + (acc, item) => { + if (!item.value) { + acc.parts.push(item.part) + return acc + } + + const start = value.indexOf(item.value, acc.offset) + if (start === -1) { + return acc + } + + const end = start + item.value.length + acc.offset = end + + if (item.part.type === "file" && item.part.source?.text) { + acc.parts.push({ + ...item.part, + source: { + ...item.part.source, + text: { + ...item.part.source.text, + start, + end, + }, + }, + }) + return acc + } + + if (item.part.type === "agent" && item.part.source) { + acc.parts.push({ + ...item.part, + source: { + ...item.part.source, + start, + end, + }, + }) + return acc + } + + acc.parts.push(item.part) + return acc + }, + { + offset: 0, + parts: [] as PromptInfo["parts"], + }, + ).parts + + return { + input: value, + parts, + cursor, + } + } + + function togglePaste() { + const offset = input.visualCursor.offset + const hit = input.extmarks + .getAllForTypeId(promptPartTypeId) + .flatMap((extmark) => { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + if (partIndex === undefined) { + return [] + } + + const part = store.prompt.parts[partIndex] + if (part?.type !== "text") { + return [] + } + + if (!part.source?.text) { + return [] + } + + if (!part.source.text.value.startsWith("[Pasted ")) { + return [] + } + + return [{ extmark, part, partIndex, collapsed: part.source.text.value }] + }) + .find((item) => offset >= item.extmark.start && offset < item.extmark.end) + + if (!hit) { + return + } + + const collapsed = hit.collapsed + const current = store.prompt.input.slice(hit.extmark.start, hit.extmark.end) + let text = hit.part.text + if (current !== collapsed) { + text = current + } + + let replacement = collapsed + if (current === collapsed) { + replacement = hit.part.text + } + const delta = replacement.length - (hit.extmark.end - hit.extmark.start) + const nextInput = + store.prompt.input.slice(0, hit.extmark.start) + replacement + store.prompt.input.slice(hit.extmark.end) + + const nextParts = store.prompt.parts.map((part, index) => { + if (part.type === "text" && part.source?.text) { + if (index === hit.partIndex) { + return { + ...part, + text, + source: { + ...part.source, + text: { + ...part.source.text, + start: hit.extmark.start, + end: hit.extmark.start + replacement.length, + }, + }, + } + } + + if (part.source.text.start < hit.extmark.end) { + return part + } + + return { + ...part, + source: { + ...part.source, + text: { + ...part.source.text, + start: part.source.text.start + delta, + end: part.source.text.end + delta, + }, + }, + } + } + + if (part.type === "file" && part.source?.text) { + if (part.source.text.start < hit.extmark.end) { + return part + } + + return { + ...part, + source: { + ...part.source, + text: { + ...part.source.text, + start: part.source.text.start + delta, + end: part.source.text.end + delta, + }, + }, + } + } + + if (part.type === "agent" && part.source) { + if (part.source.start < hit.extmark.end) { + return part + } + + return { + ...part, + source: { + ...part.source, + start: part.source.start + delta, + end: part.source.end + delta, + }, + } + } + + return part + }) + + const cursor = hit.extmark.start + Math.min(Math.max(0, offset - hit.extmark.start), replacement.length) + + input.setText(nextInput) + setStore("prompt", { + input: nextInput, + parts: nextParts, + }) + restoreExtmarksFromParts(nextParts) + input.cursorOffset = cursor + + const view = input.editorView.getViewport() + const max = Math.max(0, input.editorView.getTotalVirtualLineCount() - view.height) + if (view.offsetY > max) { + input.editorView.setViewport(view.offsetX, max, view.width, view.height, false) + } + + setTimeout(() => { + if (!input || input.isDestroyed) { + return + } + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + command.register(() => [ { title: "Stash prompt", @@ -697,26 +996,11 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - - // Expand pasted text inline before submitting - const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) - - for (const extmark of sortedExtmarks) { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } - } + const expanded = expandText() + let inputText = expanded.input - // Filter out text parts (pasted content) since they're now expanded inline - const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + // Keep the same submit behavior: only send non-text parts separately. + const nonTextParts = expanded.parts // Capture mode before it gets reset const currentMode = store.mode @@ -1173,6 +1457,7 @@ export function Prompt(props: PromptProps) { }, 0) }} onMouseDown={(r: MouseEvent) => r.target?.focus()} + onMouseUp={togglePaste} focusedBackgroundColor={theme.backgroundElement} cursorColor={theme.text} syntaxStyle={syntax()} From e3797cf69ed9a7e5249c7ff7121f716605f23b9d Mon Sep 17 00:00:00 2001 From: mounir Haddou Date: Mon, 6 Apr 2026 01:00:37 +0100 Subject: [PATCH 3/5] test(opencode): stabilize runner cancel interruption test Reduce flakiness in the Runner cancel interruption test under CI load. - Replace timing-based startup wait with an explicit Deferred started signal. - Run cancel through a forked fiber and await it with a bounded timeout. - Await the ensureRunning fiber with a bounded timeout before asserting failure. This avoids hangs where scheduling jitter causes cancellation/await ordering to become nondeterministic. --- packages/opencode/test/effect/runner.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 9dc395876ee0..138095475128 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -115,16 +115,24 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + const started = yield* Deferred.make() + const work = Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.as("never")) + }) + + const fiber = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + yield* Deferred.await(started) expect(runner.busy).toBe(true) expect(runner.state._tag).toBe("Running") - yield* runner.cancel + const stop = yield* runner.cancel.pipe(Effect.forkChild) + const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("2 seconds")) + expect(Exit.isSuccess(stopExit)).toBe(true) expect(runner.busy).toBe(false) - const exit = yield* Fiber.await(fiber) - expect(Exit.isFailure(exit)).toBe(true) + const runExit = yield* Fiber.await(fiber).pipe(Effect.timeout("2 seconds")) + expect(Exit.isFailure(runExit)).toBe(true) }), ) From 7067cdaeab65a69a27125a0d2569c75a5625fe4b Mon Sep 17 00:00:00 2001 From: mounir Haddou Date: Mon, 6 Apr 2026 01:27:39 +0100 Subject: [PATCH 4/5] chore(opencode): rerun ci From 9d80f3992f413e1a055516d329d58cfa4de8625e Mon Sep 17 00:00:00 2001 From: mounir Haddou Date: Mon, 6 Apr 2026 01:35:39 +0100 Subject: [PATCH 5/5] test(app): harden status popover windows e2e interactions Stabilize the status popover test flow that intermittently fails on Windows runners due to detached/unstable tab elements. - Retry opening the status popover with force-click and keyboard fallback. - Wait for popover visibility with bounded retries before asserting. - Poll plugins tab selection state and re-resolve locator between attempts. This targets the recurring e2e failure in status-popover can switch to plugins tab without changing app runtime behavior. --- packages/app/e2e/actions.ts | 23 ++++++++++++++++++- .../app/e2e/status/status-popover.spec.ts | 20 ++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index b1c38afee5a5..5540cb9660bc 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -814,7 +814,28 @@ export async function openStatusPopover(page: Page) { if (!opened) { await expect(trigger).toBeVisible() - await trigger.click() + + for (const timeout of [1500, 3000, undefined]) { + const clicked = await trigger + .click({ force: true, timeout }) + .then(() => true) + .catch(() => false) + + if (!clicked) { + await trigger.focus().catch(() => undefined) + await page.keyboard.press("Enter").catch(() => undefined) + } + + const visible = await popoverBody + .waitFor({ state: "visible", timeout: timeout ?? 5000 }) + .then(() => true) + .catch(() => false) + + if (visible) { + break + } + } + await expect(popoverBody).toBeVisible() } diff --git a/packages/app/e2e/status/status-popover.spec.ts b/packages/app/e2e/status/status-popover.spec.ts index d53578a49106..40d74e6ddc20 100644 --- a/packages/app/e2e/status/status-popover.spec.ts +++ b/packages/app/e2e/status/status-popover.spec.ts @@ -62,11 +62,21 @@ test("status popover can switch to plugins tab", async ({ page, gotoSession }) = const { popoverBody } = await openStatusPopover(page) - const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i }) - await pluginsTab.click() - - const ariaSelected = await pluginsTab.getAttribute("aria-selected") - expect(ariaSelected).toBe("true") + await expect + .poll( + async () => { + const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i }) + const selected = await pluginsTab.getAttribute("aria-selected").catch(() => undefined) + if (selected === "true") { + return selected + } + + await pluginsTab.click({ force: true }).catch(() => undefined) + return (await pluginsTab.getAttribute("aria-selected").catch(() => undefined)) ?? "false" + }, + { timeout: 15_000 }, + ) + .toBe("true") const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first() await expect(pluginsContent).toBeVisible()