Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Tool.Info> = {
input: Tool.InferParameters<T>
Expand Down Expand Up @@ -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)
Expand Down
78 changes: 52 additions & 26 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={syntax()}
streaming={true}
content={props.part.text.trim()}
conceal={ctx.conceal()}
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
/>
</Match>
</Switch>
</box>
</Show>
<>
<Show when={reasoning() && ctx.showThinking()}>
<box
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={"_Thinking:_ " + reasoning()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Show>
<Show when={displayText()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={syntax()}
streaming={true}
content={displayText()}
conceal={ctx.conceal()}
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={displayText()}
conceal={ctx.conceal()}
fg={theme.text}
/>
</Match>
</Switch>
</box>
</Show>
</>
)
}

Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -167,6 +168,24 @@ export namespace MCP {
const clients: Record<string, MCPClient> = {}
const status: Record<string, Status> = {}

// 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)) {
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/util/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <think>...</think> and <thinking>...</thinking> blocks.
* Uses non-greedy matching to handle multiple blocks.
*/
const THINK_TAG_RE = /<(think(?:ing)?)>[\s\S]*?<\/\1>\s*/g

/**
* Strip `<think>`/`<thinking>` 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 `<think>`/`<thinking>` 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(),
}
}
10 changes: 9 additions & 1 deletion packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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] ?? [])
Expand All @@ -1105,6 +1108,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return (
<Show when={throttledText()}>
<div data-component="text-part">
<Show when={thinkReasoning()}>
<div data-component="reasoning-part">
<Markdown text={thinkReasoning()} cacheKey={part.id + "-reasoning"} />
</div>
</Show>
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} />
</div>
Expand Down
40 changes: 40 additions & 0 deletions packages/util/src/think.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Utilities for handling `<think>`/`<thinking>` 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 `<think>...</think>` and `<thinking>...</thinking>` blocks.
* Uses non-greedy matching to handle multiple blocks.
*/
const THINK_TAG_RE = /<(think(?:ing)?)>[\s\S]*?<\/\1>\s*/g

/**
* Strip `<think>`/`<thinking>` 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 `<think>`/`<thinking>` 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(),
}
}
Loading