diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f3781f1abd8..c430410b9c6 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { splitThinkBlocks } from "../../util/format" type ToolProps = { input: Tool.InferParameters @@ -490,7 +491,18 @@ export const RunCommand = cmd({ if (part.type === "text" && part.time?.end) { if (emit("text", { part })) continue - const text = part.text.trim() + const { reasoning, text: displayText } = splitThinkBlocks(part.text) + if (reasoning && args.thinking) { + const line = `Thinking: ${reasoning}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + } else { + process.stdout.write(line + EOL) + } + } + const text = displayText.trim() if (!text) continue if (!process.stdout.isTTY) { process.stdout.write(text + EOL) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 31401836766..cb6d5d51172 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -78,6 +78,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { splitThinkBlocks } from "@/util/format" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" @@ -1425,33 +1426,58 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() - const { theme, syntax } = useTheme() + const { theme, syntax, subtleSyntax } = useTheme() + const parsed = createMemo(() => splitThinkBlocks(props.part.text)) + const displayText = createMemo(() => parsed().text.trim()) + const reasoning = createMemo(() => parsed().reasoning.trim()) return ( - - - - - - - - - - - - + <> + + + + + + + + + + + + + + + + + + ) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30..60d711739e6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -23,6 +23,7 @@ import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" +import { EventEmitter } from "node:events" export namespace MCP { const log = Log.create({ service: "mcp" }) @@ -167,6 +168,24 @@ export namespace MCP { const clients: Record = {} const status: Record = {} + // Increase MaxListeners to prevent EventEmitter warnings when many + // MCP servers are connected. Each StdioClientTransport spawns a child + // process and adds listeners to its stdin/stdout/stderr pipes; the MCP + // protocol layer may also send many concurrent requests (getPrompt, + // listTools, etc.) that each add a 'drain' listener on the child + // process's stdin. The default limit of ~10 is easily exceeded. + const localCount = Object.values(config).filter( + (mcp) => typeof mcp === "object" && mcp !== null && "type" in mcp && mcp.type === "local", + ).length + if (localCount > 0) { + const needed = Math.max(localCount * 6, 30) // generous headroom for concurrent requests + EventEmitter.defaultMaxListeners = Math.max(EventEmitter.defaultMaxListeners, needed) + for (const stream of [process.stdin, process.stdout, process.stderr]) { + const current = stream.getMaxListeners() + if (current < needed) stream.setMaxListeners(needed) + } + } + await Promise.all( Object.entries(config).map(async ([key, mcp]) => { if (!isMcpConfigured(mcp)) { diff --git a/packages/opencode/src/util/format.ts b/packages/opencode/src/util/format.ts index 4ae62eac450..87473b93b2b 100644 --- a/packages/opencode/src/util/format.ts +++ b/packages/opencode/src/util/format.ts @@ -18,3 +18,35 @@ export function formatDuration(secs: number) { const weeks = Math.floor(secs / 604800) return weeks === 1 ? "~1 week" : `~${weeks} weeks` } + + +/** + * Regex to match ... and ... blocks. + * Uses non-greedy matching to handle multiple blocks. + */ +const THINK_TAG_RE = /<(think(?:ing)?)>[\s\S]*?<\/\1>\s*/g + +/** + * Strip ``/`` tag blocks from text for display purposes. + * The raw tags are preserved in storage for multi-turn LLM context. + */ +export function stripThinkTags(text: string): string { + return text.replace(THINK_TAG_RE, "").trim() +} + +/** + * Extract ``/`` blocks and remaining text separately. + * Returns the reasoning content and the cleaned display text. + */ +export function splitThinkBlocks(text: string): { reasoning: string; text: string } { + const blocks: string[] = [] + const cleaned = text.replace(THINK_TAG_RE, (match) => { + const inner = match.replace(/<\/?(?:think(?:ing)?)>/g, "").trim() + if (inner) blocks.push(inner) + return "" + }) + return { + reasoning: blocks.join("\n\n"), + text: cleaned.trim(), + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 5c110ccd66f..fda2a05a445 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -44,6 +44,7 @@ import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" +import { splitThinkBlocks } from "@opencode-ai/util/think" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" @@ -1078,7 +1079,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return items.filter((x) => !!x).join(" \u00B7 ") }) - const displayText = () => (part.text ?? "").trim() + const parsed = createMemo(() => splitThinkBlocks(part.text ?? "")) + const displayText = () => parsed().text.trim() + const thinkReasoning = () => parsed().reasoning.trim() const throttledText = createThrottledValue(displayText) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) @@ -1105,6 +1108,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return (
+ +
+ +
+
diff --git a/packages/util/src/think.ts b/packages/util/src/think.ts new file mode 100644 index 00000000000..9a396619379 --- /dev/null +++ b/packages/util/src/think.ts @@ -0,0 +1,40 @@ +/** + * Utilities for handling ``/`` tags in LLM responses. + * + * These tags are kept in storage to preserve multi-turn LLM context, + * but should be stripped or separated at the rendering layer. + */ + +/** + * Regex to match `...` and `...` blocks. + * Uses non-greedy matching to handle multiple blocks. + */ +const THINK_TAG_RE = /<(think(?:ing)?)>[\s\S]*?<\/\1>\s*/g + +/** + * Strip ``/`` tag blocks from text for display purposes. + * The raw tags are preserved in storage for multi-turn LLM context. + */ +export function stripThinkTags(text: string): string { + return text.replace(THINK_TAG_RE, "").trim() +} + +/** + * Extract ``/`` blocks and remaining text separately. + * Returns the reasoning content and the cleaned display text. + */ +export function splitThinkBlocks(text: string): { + reasoning: string + text: string +} { + const blocks: string[] = [] + const cleaned = text.replace(THINK_TAG_RE, (match) => { + const inner = match.replace(/<\/?(?:think(?:ing)?)>/g, "").trim() + if (inner) blocks.push(inner) + return "" + }) + return { + reasoning: blocks.join("\n\n"), + text: cleaned.trim(), + } +}