@@ -275,10 +275,14 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
275275 private reasoningBuffer = ''
276276 /** Whether we've already emitted a thinking summary from reasoning deltas. */
277277 private emittedReasoningSummary = false
278- /** Whether we're currently inside a <think> block in streamed text deltas. */
279- private insideThinkTag = false
280- /** Buffer for accumulating text inside <think> tags during streaming. */
281- private thinkTagBuffer = ''
278+ /**
279+ * Whether text deltas currently belong to a reasoning part rather than the
280+ * actual response. Some providers (e.g. Kimi via OpenCode) send reasoning
281+ * content as `field=text` deltas, with `part.updated type=reasoning` events
282+ * marking the boundaries. When true, text deltas are routed to the reasoning
283+ * buffer instead of being emitted as visible text.
284+ */
285+ private inReasoningPhase = false
282286
283287 constructor ( workingDir : string , opts ?: Partial < OpenCodeProcessOptions > ) {
284288 super ( )
@@ -349,9 +353,14 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
349353 this . startupTimer = null
350354 }
351355
352- // Model is stored as "providerID/modelID" — show everything after the first slash
353- const modelName = this . model ?. includes ( '/' ) ? this . model . slice ( this . model . indexOf ( '/' ) + 1 ) : ( this . model || 'opencode (default)' )
354- this . emit ( 'system_init' , modelName )
356+ // Model is stored as "providerID/modelID" — show everything after the first slash.
357+ // Only emit system_init when we have an explicit model. If model is undefined,
358+ // the frontend's model validation effect will call setModel shortly, which
359+ // triggers a restart with the correct model — no point showing a placeholder.
360+ if ( this . model ) {
361+ const modelName = this . model . includes ( '/' ) ? this . model . slice ( this . model . indexOf ( '/' ) + 1 ) : this . model
362+ this . emit ( 'system_init' , modelName )
363+ }
355364 }
356365
357366 /** Subscribe to the OpenCode SSE event stream and map events to CodingProcess events. */
@@ -471,10 +480,9 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
471480 this . deltaBufferFlushed = true
472481 if ( this . lastUserInput && this . deltaBuffer . startsWith ( this . lastUserInput ) ) {
473482 const remainder = this . deltaBuffer . slice ( this . lastUserInput . length )
474- if ( remainder ) { const clean = this . stripThinkTags ( remainder ) ; if ( clean ) this . emit ( 'text' , clean ) }
483+ if ( remainder ) this . emit ( 'text' , remainder )
475484 } else {
476- const clean = this . stripThinkTags ( this . deltaBuffer )
477- if ( clean ) this . emit ( 'text' , clean )
485+ this . emit ( 'text' , this . deltaBuffer )
478486 }
479487 this . deltaBuffer = ''
480488 }
@@ -495,6 +503,23 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
495503 }
496504 if ( field === 'text' && delta ) {
497505 this . receivedDeltas = true
506+
507+ // Some providers (e.g. Kimi via OpenCode) send reasoning content as
508+ // field=text deltas. When inReasoningPhase is set (by a preceding
509+ // part.updated type=reasoning event), route to reasoning buffer.
510+ if ( this . inReasoningPhase ) {
511+ this . reasoningBuffer += delta
512+ if ( this . reasoningBuffer . length > 20 && ! this . emittedReasoningSummary ) {
513+ this . emittedReasoningSummary = true
514+ const match = this . reasoningBuffer . match ( / ^ ( .+ ?[ . ! ? \n ] ) / )
515+ const summary = match && match [ 1 ] . length <= 120
516+ ? match [ 1 ] . replace ( / \n / g, ' ' ) . trim ( )
517+ : this . reasoningBuffer . slice ( 0 , 80 ) . trim ( )
518+ this . emit ( 'thinking' , summary )
519+ }
520+ break
521+ }
522+
498523 // Buffer initial deltas to detect and strip user echo prefix.
499524 // Some providers echo the user message at the start of the assistant
500525 // response, which causes duplicate display.
@@ -504,19 +529,15 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
504529 this . deltaBufferFlushed = true
505530 if ( this . deltaBuffer . startsWith ( this . lastUserInput ) ) {
506531 const remainder = this . deltaBuffer . slice ( this . lastUserInput . length )
507- if ( remainder ) { const clean = this . stripThinkTags ( remainder ) ; if ( clean ) this . emit ( 'text' , clean ) }
532+ if ( remainder ) this . emit ( 'text' , remainder )
508533 } else {
509- const clean = this . stripThinkTags ( this . deltaBuffer )
510- if ( clean ) this . emit ( 'text' , clean )
534+ this . emit ( 'text' , this . deltaBuffer )
511535 }
512536 this . deltaBuffer = ''
513537 }
514538 // Still buffering — don't emit yet
515539 } else {
516- // Strip <think>...</think> tags — some models (e.g. Kimi) embed
517- // chain-of-thought reasoning in the text output using these tags.
518- const clean = this . stripThinkTags ( delta )
519- if ( clean ) this . emit ( 'text' , clean )
540+ this . emit ( 'text' , delta )
520541 }
521542 } else if ( field === 'reasoning' && delta ) {
522543 // Accumulate reasoning deltas and emit a thinking summary once we
@@ -548,6 +569,11 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
548569
549570 switch ( part . type ) {
550571 case 'text' : {
572+ // A text part.updated signals that text deltas are now actual
573+ // response text, not reasoning. Clear the reasoning phase flag.
574+ if ( this . inReasoningPhase ) {
575+ this . inReasoningPhase = false
576+ }
551577 // Text may arrive via message.part.delta (streaming) or as full
552578 // content here (OpenCode >=1.4 message.updated). Only emit if we
553579 // haven't already streamed it via delta events or emitted it from
@@ -559,18 +585,21 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
559585 if ( this . lastUserInput && text . startsWith ( this . lastUserInput ) ) {
560586 text = text . slice ( this . lastUserInput . length )
561587 }
562- // Strip <think>...</think> blocks from full text
563- text = OpenCodeProcess . stripThinkTagsFull ( text )
564588 if ( text ) this . emit ( 'text' , text )
565589 }
566590 break
567591 }
568592
569593 case 'reasoning' : {
594+ // A reasoning part.updated signals that subsequent text deltas
595+ // are reasoning content, not visible text. Set the phase flag so
596+ // the delta handler routes them to the reasoning buffer.
597+ this . inReasoningPhase = true
570598 // OpenCode uses 'text' field, not 'content'. Reasoning may be
571599 // empty or encrypted (e.g. OpenAI models). Only emit if present.
572600 const content = part . text || ''
573- if ( content . length > 20 ) {
601+ if ( content . length > 20 && ! this . emittedReasoningSummary ) {
602+ this . emittedReasoningSummary = true
574603 const match = content . match ( / ^ ( .+ ?[ . ! ? \n ] ) / )
575604 const summary = match && match [ 1 ] . length <= 120
576605 ? match [ 1 ] . replace ( / \n / g, ' ' ) . trim ( )
@@ -760,67 +789,6 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
760789 }
761790 }
762791
763- /**
764- * Strip `<think>...</think>` tags from text content. Some models (e.g. Kimi)
765- * embed chain-of-thought reasoning in the text output using these tags rather
766- * than using a separate reasoning/thinking field.
767- * Returns the text with think blocks removed; thinking content is routed to
768- * the thinking summary emitter.
769- */
770- private stripThinkTags ( text : string ) : string {
771- // Fast path: no think tags at all
772- if ( ! text . includes ( '<think' ) && ! text . includes ( '</think' ) && ! this . insideThinkTag ) {
773- return text
774- }
775-
776- let result = ''
777- let i = 0
778- while ( i < text . length ) {
779- if ( this . insideThinkTag ) {
780- const closeIdx = text . indexOf ( '</think>' , i )
781- if ( closeIdx === - 1 ) {
782- // Still inside think block, buffer the rest
783- this . thinkTagBuffer += text . slice ( i )
784- i = text . length
785- } else {
786- this . thinkTagBuffer += text . slice ( i , closeIdx )
787- this . insideThinkTag = false
788- // Emit thinking summary from accumulated think content
789- if ( this . thinkTagBuffer . length > 20 && ! this . emittedReasoningSummary ) {
790- this . emittedReasoningSummary = true
791- const match = this . thinkTagBuffer . match ( / ^ ( .+ ?[ . ! ? \n ] ) / )
792- const summary = match && match [ 1 ] . length <= 120
793- ? match [ 1 ] . replace ( / \n / g, ' ' ) . trim ( )
794- : this . thinkTagBuffer . slice ( 0 , 80 ) . trim ( )
795- this . emit ( 'thinking' , summary )
796- }
797- this . thinkTagBuffer = ''
798- i = closeIdx + '</think>' . length
799- }
800- } else {
801- const openIdx = text . indexOf ( '<think>' , i )
802- if ( openIdx === - 1 ) {
803- result += text . slice ( i )
804- i = text . length
805- } else {
806- result += text . slice ( i , openIdx )
807- this . insideThinkTag = true
808- i = openIdx + '<think>' . length
809- }
810- }
811- }
812- return result
813- }
814-
815- /**
816- * Strip `<think>...</think>` blocks from a complete text string (non-streaming).
817- * Returns the text with all think blocks removed.
818- */
819- private static stripThinkTagsFull ( text : string ) : string {
820- // Remove complete <think>...</think> blocks (including multiline)
821- return text . replace ( / < t h i n k > [ \s \S ] * ?< \/ t h i n k > \s * / g, '' ) . trimStart ( )
822- }
823-
824792 /**
825793 * Detect TodoWrite/TaskCreate/TaskUpdate tool calls and emit todo_update events.
826794 * Mirrors the task-tracking logic in ClaudeProcess.handleTaskTool().
@@ -889,8 +857,7 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
889857 this . deltaBufferFlushed = false
890858 this . reasoningBuffer = ''
891859 this . emittedReasoningSummary = false
892- this . insideThinkTag = false
893- this . thinkTagBuffer = ''
860+ this . inReasoningPhase = false
894861
895862 const baseUrl = `http://localhost:${ serverState . port } `
896863 // Parse [Attached files: ...] prefix and convert image paths to proper parts.
0 commit comments