From 6e24e397115cc32ed8aa5ed0368d44f17a115471 Mon Sep 17 00:00:00 2001 From: Eugene B <35495675+BEEugene@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:57:05 +0300 Subject: [PATCH 1/4] chore: gitignore local build/log artifacts --- .gitignore | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.gitignore b/.gitignore index 006cab8c276c..6be7c2d9abf0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,29 @@ node_modules .worktrees .sst .env + +# Local Sisyphus session debug artifacts +build_out.log +build_err.log +build.log +build.log.err +build-bundle.log +tc_err.log +tc_out.log +tui_err.log +tui_in.txt +tui_out.log +displayparts.log +packages/opencode/build-direct.log +PR-READY.md +.commit-msg.txt +mcp-call-test.ps1 +mcp-call-test2.ps1 +mcp-err.txt +mcp-out.txt +mcp-req.jsonl +mcp-smoke.ps1 +sync.ffs_db .env.local .idea .vscode From 894a8ae199526adb541bb7452808b1218da9d4ac Mon Sep 17 00:00:00 2001 From: Eugene B <35495675+BEEugene@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:57:35 +0300 Subject: [PATCH 2/4] fix(tui): explicitly register spinner intrinsic to survive bundler tree-shaking Bun's bundler drops the side-effect-only `import "opentui-spinner/solid"` when compiling the TUI. Confirmed by `extend({spinner` being absent from the bundled binary while the `dots` (cli-spinners default) string was present. Result: `[Reconciler] Unknown component type: spinner` at runtime for any session that renders a Spinner. Switch to a named import of SpinnerRenderable and an explicit `extend({spinner})` call. Both bindings are used, so the bundler cannot drop the registration. --- packages/tui/src/component/spinner.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index 700780314131..837c1f237f3f 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -1,9 +1,17 @@ import { Show } from "solid-js" +import { extend } from "@opentui/solid/components" +import { SpinnerRenderable } from "opentui-spinner" import { useTheme } from "../context/theme" import { useKV } from "../context/kv" import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" -import "opentui-spinner/solid" + +// Register the intrinsic element explicitly. The previous +// side-effect-only `import "opentui-spinner/solid"` got tree-shaken by +// Bun's bundler at compile time — confirmed by `extend({spinner` being +// absent from the bundled binary while `dots` (cli-spinners default) +// was present. Explicit named import + call survives tree-shaking. +extend({ spinner: SpinnerRenderable }) export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] From 96c3c4c91e5d2f3b8d2ed717c454e4e76a108ba8 Mon Sep 17 00:00:00 2001 From: Eugene B <35495675+BEEugene@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:57:51 +0300 Subject: [PATCH 3/4] fix(plugin): warn instead of fail on tui plugin loader errors The plugin loader already filters out failed plugins, so the report.error callback's `fail()` calls were producing noise without blocking anything. Convert all four stages (install, compatibility, entry, default) to `warn()` so the TUI surfaces the issue but doesn't make the process unstartable on a transient plugin error (e.g. a network blip on `git ls-remote`). --- packages/opencode/src/plugin/tui/runtime.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 4673805cf3bc..0851536a8234 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -755,18 +755,18 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P error(candidate, retry, stage, error, resolved) { const spec = candidate.plan.spec if (stage === "install") { - fail("failed to resolve tui plugin", { path: spec, retry, error }) + warn("failed to resolve tui plugin", { path: spec, retry, error }) return } if (stage === "compatibility") { - fail("tui plugin incompatible", { path: spec, retry, error }) + warn("tui plugin incompatible", { path: spec, retry, error }) return } if (stage === "entry") { - fail("failed to resolve tui plugin entry", { path: spec, retry, error }) + warn("failed to resolve tui plugin entry", { path: spec, retry, error }) return } - fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error }) + warn("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error }) }, }, }) From 2dc380c81b05b77e8c5f6ecfad8e00fdc0411fd5 Mon Sep 17 00:00:00 2001 From: Eugene B <35495675+BEEugene@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:58:14 +0300 Subject: [PATCH 4/4] fix(tui): collapse fragmented reasoning parts and strip thinking echoes from text Some models (notably MiniMax-M3 with extended thinking, but also DeepSeek-R1, GLM-Z1, and other OpenAI-compatible reasoning providers) fragment a single line of reasoning across many short ReasoningPart chunks, sometimes interleaved with TextPart echoes of the same content. The TUI rendered this as dozens of "Thought: Xms" boxes plus a duplicate text paragraph in the assistant message. Root cause: certain providers stream the model's `reasoning_content` field as discrete reasoning events AND also echo the same text into the regular `content` field for back-compat. opencode's `SessionProcessor.handleEvent` correctly persists both as separate parts (`processor.ts:371-425`), but the TUI then renders them as duplicates. Dedupe at the TUI layer is the right place because not every provider does this, and the echoed text often contains the final response appended after the echo (we have to keep that tail). - Aggregate ALL reasoning parts into one block at the top of the message (not just consecutive ones; the model can interleave non-reasoning parts in between). - Strip ``/`` tags individually from text parts. The opening/closing tags often end up split across the reasoning/text boundary, so a paired regex misses them. - Dedup on a normalized fingerprint (lowercase, whitespace + Unicode punctuation stripped) and use substring `includes` (not prefix/suffix only) so 4-part alternating streams A B A' B' are caught. - For text parts that begin with the merged reasoning, strip the echo prefix and keep only the tail (the actual response) using a character-level diff that skips whitespace/punctuation on both sides. A debug logging hook (`DEBUG_DEDUP_LOG = false` by default) writes the part sequence, fingerprints, and merge decisions to displayparts.log when enabled, kept for future regressions. Closes #31999 --- packages/tui/src/routes/session/index.tsx | 203 +++++++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 922272f0e759..bfc0c3937a0f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -17,6 +17,7 @@ import { import { Dynamic } from "solid-js/web" import path from "node:path" import { mkdir, writeFile } from "node:fs/promises" +import { appendFileSync } from "node:fs" import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" import { useSync } from "../../context/sync" @@ -1487,15 +1488,213 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const childShortcut = useCommandShortcut("session.child.first") const backgroundShortcut = useCommandShortcut("session.background") + // Collapse all reasoning parts into a single block (regardless of whether + // they were adjacent in the part stream) so models that fragment extended + // thinking across reasoning/text/reasoning boundaries don't render + // scattered "Thought" boxes. Some models (e.g. MiniMax-M3) also echo the + // thinking content into the text part inside ``/`` tags + // (sometimes split across the reasoning/text boundary, sometimes already + // opened inside reasoning and closed inside text) — strip those so the + // response text stays clean. While merging, drop duplicate reasoning + // chunks that streaming models occasionally emit. We compare on a + // normalized fingerprint (lowercased, no whitespace, no Unicode + // punctuation) so "friendlyhello back" matches "friendly hello back" and + // use SUBSTRING `includes` (not prefix/suffix only) so we catch duplicates + // even when the model interleaves new content between re-sends of the + // same chunk (e.g. 4-part stream: A B A B where the second A and B are + // near-duplicates of the first). After merging, we also strip the + // reasoning echo from any text part whose content STARTS with the + // reasoning (the model often streams "thinking aloud" into the text + // part, then appends the actual response — we keep only the response). + // The sync store keeps every part; this only changes what the TUI shows. + // + // DEBUG: when reasoning dedup misses a case, set DEBUG_DEDUP_LOG = true + // and check C:/Users/user/Downloads/opencode_work/displayparts.log + // for the part sequence, fingerprints, and merge decisions. + const DEBUG_DEDUP_LOG = false + const DEDUP_LOG_PATH = "C:/Users/user/Downloads/opencode_work/displayparts.log" + const dedupLog = (msg: string) => { + if (!DEBUG_DEDUP_LOG) return + try { + appendFileSync(DEDUP_LOG_PATH, `${new Date().toISOString().slice(11, 23)} ${msg}\n`) + } catch {} + } + // Find the position in `text` after the content equivalent to `reasoning` + // (modulo whitespace / Unicode punctuation / case). Returns -1 if `text` + // does not start with the reasoning in fingerprint space. Used to strip + // the reasoning echo from a text part that begins with the same content + // the model streamed into the ReasoningPart, so the tail (the actual + // response) is preserved verbatim. + const findReasoningEndInText = (reasoning: string, text: string): number => { + let rIdx = 0 + let tIdx = 0 + const rLower = reasoning.toLowerCase() + while (rIdx < rLower.length && tIdx < text.length) { + const rChar = rLower[rIdx] + const tChar = text[tIdx].toLowerCase() + if (rChar === tChar) { + rIdx++ + tIdx++ + } else if (/[\s\p{P}]/u.test(rChar)) { + rIdx++ + } else if (/[\s\p{P}]/u.test(tChar)) { + tIdx++ + } else { + return -1 + } + } + if (rIdx < rLower.length) return -1 + while (tIdx < text.length && /[\s\p{P}]/u.test(text[tIdx])) tIdx++ + return tIdx + } + const displayParts = createMemo(() => { + const stripThink = (s: string) => s.replace(/<\/?think>/g, "").trim() + // For dedup comparison only — strips whitespace, punctuation, and case + // so "friendlyhello back." matches "friendly hello back." Display keeps + // the original whitespace. + const fingerprint = (s: string) => s.toLowerCase().replace(/[\s\p{P}]/gu, "") + const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n) + "…" : s) + dedupLog(`--- memo run: ${props.parts.length} parts, msg=${props.message.id} ---`) + const reasoningParts: ReasoningPart[] = [] + const otherParts: Part[] = [] + for (let i = 0; i < props.parts.length; i++) { + const part = props.parts[i] + if (part.type === "reasoning") { + const cleaned = stripThink(part.text) + dedupLog( + ` in[${i}] reasoning rawLen=${part.text.length} cleanLen=${cleaned.length} fp=${truncate(fingerprint(cleaned), 60)} text=${truncate(JSON.stringify(cleaned), 120)}`, + ) + if (cleaned) reasoningParts.push({ ...part, text: cleaned }) + } else if (part.type === "text") { + const cleaned = stripThink(part.text) + dedupLog( + ` in[${i}] text rawLen=${part.text.length} cleanLen=${cleaned.length} fp=${truncate(fingerprint(cleaned), 60)} text=${truncate(JSON.stringify(cleaned), 120)}`, + ) + if (cleaned) otherParts.push({ ...part, text: cleaned }) + } else { + dedupLog(` in[${i}] ${part.type} (passthrough)`) + otherParts.push(part) + } + } + if (reasoningParts.length === 0) { + dedupLog(`--- result: 0 reasoning parts, ${otherParts.length} other parts ---`) + return otherParts + } + dedupLog(` >> reducing ${reasoningParts.length} reasoning parts`) + const merged: ReasoningPart = reasoningParts.reduce( + (acc, curr) => { + const accFp = fingerprint(acc.text) + const currFp = fingerprint(curr.text) + const accEnd = curr.time.end ?? acc.time.end + let action: string + let next: ReasoningPart + if (accFp.length > 0) { + if (accFp.includes(currFp) && currFp.length > 0) { + // curr is fully covered by acc (anywhere in acc's text, modulo + // whitespace/punct/case). Drop curr — it's a re-sent chunk. + action = "DROP curr (acc.includes(curr))" + next = { ...acc, time: { start: acc.time.start, end: accEnd } } + dedupLog( + ` merge: ${action} accFpLen=${accFp.length} currFpLen=${currFp.length} accFpTail=${truncate(accFp, 30)} currFpHead=${truncate(currFp, 30)}`, + ) + return next + } + if (currFp.includes(accFp) && accFp.length > 0) { + // acc is fully covered by curr (curr is a strict rewrite of acc + // with extra trailing content). Replace acc with curr. + action = "REPLACE acc with curr (curr.includes(acc))" + next = { ...acc, text: curr.text, time: { start: acc.time.start, end: accEnd } } + dedupLog( + ` merge: ${action} accFpLen=${accFp.length} currFpLen=${currFp.length} accFpTail=${truncate(accFp, 30)} currFpHead=${truncate(currFp, 30)}`, + ) + return next + } + // No full containment — fall back to plain concat. (Earlier we had + // strict prefix/suffix checks here; they missed the 4-part + // alternating case A B A B where the second A is contained in + // acc (A+B) but neither at the start nor at the end. The + // substring-includes checks above cover those.) + action = "CONCAT (no overlap detected)" + } else { + action = "CONCAT (acc was empty)" + } + next = { + ...acc, + text: acc.text + curr.text, + time: { start: acc.time.start, end: accEnd }, + } + dedupLog( + ` merge: ${action} accFpLen=${accFp.length} currFpLen=${currFp.length} accFpTail=${truncate(accFp, 30)} currFpHead=${truncate(currFp, 30)}`, + ) + return next + }, + ) + // Second pass: handle text parts that echo the merged reasoning. + // (a) exact equal to reasoning → drop (no new content) + // (b) subset of reasoning → drop (text is just a fragment of reasoning) + // (c) starts with reasoning → STRIP the echo, keep the tail (the + // actual response, e.g. "Привет! Чем могу помочь?") + // (d) superset of reasoning (reasoning inside, text wraps it) → keep + // (e) no overlap → keep + // Stripping (c) uses a character-level diff that skips whitespace and + // Unicode punctuation on both sides, so reasoning "askwhat they need." + // and text "ask what they need." still align correctly and the response + // after the echo is preserved verbatim (with its original whitespace). + const reasoningFp = fingerprint(merged.text) + const transformedOtherParts = otherParts + .map((part) => { + if (part.type !== "text" || reasoningFp.length === 0) return part + const partFp = fingerprint(part.text) + if (partFp.length === 0) return part + if (reasoningFp === partFp) { + dedupLog( + ` text-dedup: DROP (exact equal to reasoning) reasoningFpLen=${reasoningFp.length} partFp=${truncate(partFp, 50)}`, + ) + return null + } + if (reasoningFp.includes(partFp)) { + dedupLog( + ` text-dedup: DROP (subset of reasoning) reasoningFpLen=${reasoningFp.length} partFp=${truncate(partFp, 50)}`, + ) + return null + } + if (partFp.startsWith(reasoningFp)) { + // Text begins with the reasoning content. Walk the original + // (non-normalized) text and find the position right after the + // reasoning ends, then keep only the tail. + const endPos = findReasoningEndInText(merged.text, part.text) + if (endPos > 0 && endPos < part.text.length) { + const remaining = part.text.slice(endPos).trim() + if (remaining.length > 0) { + dedupLog( + ` text-strip: stripped reasoning echo at pos=${endPos}, kept ${remaining.length} chars: ${truncate(JSON.stringify(remaining), 80)}`, + ) + return { ...part, text: remaining } + } + } + dedupLog( + ` text-dedup: DROP (text started with reasoning echo, nothing left after strip) partFp=${truncate(partFp, 50)}`, + ) + return null + } + return part + }) + .filter((p): p is Part => p !== null) + dedupLog( + `--- result: merged reasoning len=${merged.text.length} text=${truncate(JSON.stringify(merged.text), 200)}; otherParts ${otherParts.length}→${transformedOtherParts.length} after text-dedup ---`, + ) + return [merged, ...transformedOtherParts] + }) + return ( <> - + {(part, index) => { const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return (