返回 DeepSeek-Reasonix
chat_tui_events.go
根目录 / internal / cli / chat_tui_events.go
1 package cli
2
3 import (
4 "fmt"
5 "reasonix/internal/event"
6 "reasonix/internal/i18n"
7 "strings"
8 "time"
9 )
10
11 func (m *chatTUI) ingestEvent(e event.Event) {
12 if m.ingestPreflight(e) {
13 return
14 }
15 switch e.Kind {
16 case event.Reasoning:
17 m.ingestReasoning(e)
18 case event.Text:
19 m.ingestText(e)
20 case event.Message:
21 m.ingestMessage(e)
22 case event.ToolDispatch:
23 m.ingestToolDispatch(e)
24 case event.ToolProgress:
25 m.ingestToolProgress(e)
26 case event.ToolResult:
27 m.ingestToolResult(e)
28 case event.Usage:
29 m.ingestUsage(e)
30 case event.ReadStatus:
31 m.ingestReadStatus(e)
32 case event.TurnPhase:
33 m.ingestTurnPhase(e)
34 case event.CompletionSummary:
35 m.ingestCompletionSummary(e)
36 case event.Notice:
37 m.ingestNotice(e)
38 case event.GuardianAssessment:
39 m.ingestGuardianAssessment(e)
40 case event.ExtensionStatus:
41 m.ingestExtensionStatus(e)
42 case event.ExtensionSurface:
43 m.ingestExtensionSurface(e)
44 case event.CompactionStarted:
45 m.ingestCompactionStarted(e)
46 case event.CompactionDone:
47 m.ingestCompactionDone(e)
48 case event.SessionOperation:
49 m.ingestSessionOperation(e)
50 case event.Phase:
51 m.ingestPhase(e)
52 case event.ApprovalRequest:
53 m.ingestApprovalRequest(e)
54 case event.AskRequest:
55 m.ingestAskRequest(e)
56 case event.MCPInteractionRequest:
57 m.ingestMCPInteractionRequest(e)
58 case event.MCPSurfaceReady:
59 m.ingestMCPSurfaceReady(e)
60 case event.TurnDone:
61 m.ingestTurnDone(e)
62 }
63 }
64
65 func (m *chatTUI) ingestReasoning(e event.Event) {
66 if m.nativeScrollback {
67 if !m.reasoningNative {
68 m.thinkStart = time.Now()
69 m.reasoningNative = true
70 }
71 m.streamReasoning(e.Text)
72 return
73 }
74 if m.reasoningLineIdx < 0 {
75 // Show the marker plus a live text block the moment thinking starts; the
76 // text streams in below it and the block collapses to "thought for Ns"
77 // when it closes (kept expanded only in verbose mode).
78 m.commitSpacer()
79 m.thinkStart = time.Now()
80 m.reasoningLineIdx = len(m.transcript)
81 m.commitLine(dim(" ▎ " + i18n.M.ChatThinking))
82 m.reasoningTextIdx = len(m.transcript)
83 m.commitLine("")
84 m.reasoningView = m.reasoningView[:0]
85 }
86 m.streamReasoning(e.Text)
87 }
88
89 func (m *chatTUI) ingestText(e event.Event) {
90 m.commitReasoningBeforeAnswer()
91 m.pending.WriteString(e.Text)
92 m.streamAnswer()
93 }
94
95 func (m *chatTUI) ingestMessage(e event.Event) {
96 // The answer stream is complete — freeze reasoning + the markdown answer.
97 // Message.Text is the canonical display text (protocol markers already
98 // stripped at emission), so it replaces the raw streamed accumulation.
99 if e.Text != "" && m.pending.Len() > 0 {
100 m.pending.Reset()
101 m.pending.WriteString(e.Text)
102 }
103 m.writeSearchFootnotes()
104 m.commitReasoning()
105 m.commitPending()
106 }
107
108 func (m *chatTUI) ingestToolDispatch(e event.Event) {
109 // The early (partial) dispatch only carries the name — the full dispatch
110 // with args prints the line. Same-ID preview refreshes are ignored because
111 // native scrollback cannot replace an already-printed diff card.
112 if e.Tool.Partial || e.Tool.Refreshed {
113 return
114 }
115 m.finalizeStreamed()
116 switch e.Tool.Name {
117 case "todo_write":
118 // The result decides whether this list becomes canonical; dispatch only
119 // means the model asked for an update.
120 case planApprovalTool:
121 // No longer a tool, but guard anyway: the plan is the assistant's reply.
122 default:
123 m.commitSpacer()
124 if block := diffBlock(e.Tool.Name, e.Tool.Args, e.Tool.FileDiff, m.width, m.diffMaxLines); block != nil {
125 for _, ln := range block {
126 m.commitLine(ln)
127 }
128 return
129 }
130 m.commitTranscriptSource(transcriptSource{
131 kind: transcriptSourceToolCard, raw: e.Tool.Name, aux: e.Tool.Args,
132 })
133 m.beginToolRunning(e.Tool.ID)
134 }
135 }
136
137 func (m *chatTUI) ingestToolProgress(e event.Event) {
138 if event.IsSubagentProgressName(e.Tool.Name) {
139 m.streamSubagentProgress(e.Tool)
140 return
141 }
142 // Unknown names in the reserved namespace may come from a newer agent.
143 // Keep them out of ordinary tool output even though this CLI cannot render
144 // their payload yet.
145 if event.IsReservedSubagentProgressName(e.Tool.Name) {
146 return
147 }
148 m.streamToolOutput(e.Tool.ID, e.Tool.Output)
149 }
150
151 func (m *chatTUI) ingestToolResult(e event.Event) {
152 // A successful result is silent (it only feeds the model); a blocked/failed
153 // call surfaces a red card. Pass the final output so collapseToolOutput has
154 // a last-resort line count when live state was already reset.
155 m.collapseFinalToolOutput(e.Tool)
156 if e.Tool.Name == "todo_write" && e.Tool.Err == "" && e.Tool.TodoWritten {
157 m.todos = append([]event.Todo(nil), e.Tool.Todos...)
158 m.todosDismissed = false
159 }
160 m.rememberSearchResult(e.Tool)
161 if e.Tool.Err != "" {
162 m.finalizeStreamed()
163 label := shellToolDisplayName(e.Tool.Name, e.Tool.Execution)
164 detail := shellFailureDetail(e.Tool.Execution)
165 errText := e.Tool.Err
166 if detail != "" {
167 errText = detail + " · " + errText
168 }
169 m.commitLine(" " + red("●") + " " + bold(label) + " " + red("⊘ "+errText))
170 }
171 }
172
173 func (m *chatTUI) ingestUsage(e event.Event) {
174 if e.Usage != nil {
175 m.turnTokens += e.Usage.CompletionTokens
176 }
177 m.addSessionCostQuote(e.CostQuote)
178 if m.showTurnUsage {
179 if line := renderQuotedTurnReceipt(e.Usage, e.CostQuote, e.CacheDiagnostics); line != "" {
180 m.finalizeStreamed()
181 m.commitSpacer()
182 m.commitTranscriptSource(transcriptSource{kind: transcriptSourceTurnReceipt, raw: line})
183 }
184 }
185 }
186
187 func (m *chatTUI) ingestReadStatus(e event.Event) {
188 m.ingest(e.ReadStatus)
189 }
190
191 func (m *chatTUI) ingestTurnPhase(e event.Event) {
192 // Content-free host phase for the live status line only.
193 if phase := strings.TrimSpace(string(e.PhaseName)); phase != "" {
194 m.turnPhase = phase
195 } else if phase := strings.TrimSpace(e.Text); phase != "" {
196 m.turnPhase = phase
197 }
198 }
199
200 func (m *chatTUI) ingestCompletionSummary(e event.Event) {
201 if e.Completion != nil {
202 if completionSummaryNeedsAttention(e.Completion, "") {
203 m.finalizeStreamed()
204 m.commitLine(fmt.Sprintf(" ! %s", completionSummaryWarning(e.Completion)))
205 }
206 if m.showReasoning {
207 m.finalizeStreamed()
208 m.commitLine(dim(" · " + formatCompletionSummaryLine(e.Completion)))
209 }
210 }
211 }
212
213 func (m *chatTUI) ingestNotice(e event.Event) {
214 glyph := "·"
215 if e.Level == event.LevelWarn {
216 glyph = "!"
217 }
218 m.finalizeStreamed()
219 m.commitLine(fmt.Sprintf(" %s %s", glyph, e.Text))
220 }
221
222 func (m *chatTUI) ingestGuardianAssessment(e event.Event) {
223 m.finalizeStreamed()
224 g := e.Guardian
225 line := fmt.Sprintf("Guardian %s · %s", g.Outcome, g.Tool)
226 if g.Subject != "" {
227 line += " · " + truncateSubject(g.Subject, m.width)
228 }
229 if g.RiskLevel != "" {
230 line += " · risk=" + g.RiskLevel
231 }
232 if g.UserAuthorization != "" {
233 line += " · authorization=" + g.UserAuthorization
234 }
235 if g.Rationale != "" {
236 line += " · " + g.Rationale
237 }
238 if g.Outcome == "deny" {
239 m.commitLine(" ! " + line)
240 } else {
241 m.commitLine(" · " + line)
242 }
243 }
244
245 func (m *chatTUI) ingestExtensionStatus(e event.Event) {
246 // One-line status contribution from an extension sidecar — a
247 // severity-aware notice line, like event.Notice.
248 if line := extensionStatusLine(e.Extension); line != "" {
249 m.finalizeStreamed()
250 m.commitLine(line)
251 }
252 }
253
254 func (m *chatTUI) ingestExtensionSurface(e event.Event) {
255 // A published card/form renders as a transcript card; a notification
256 // renders as a notice line. Form fields themselves arrive through the
257 // Ask machinery (the hub translates them), so no dialog work here.
258 m.finalizeStreamed()
259 if e.Extension != nil && e.Extension.Notification != nil {
260 if line := extensionNotificationLine(e.Extension); line != "" {
261 m.commitLine(line)
262 }
263 return
264 }
265 for _, ln := range extensionSurfaceLines(e.Extension, m.width) {
266 m.commitLine(ln)
267 }
268 }
269
270 func (m *chatTUI) ingestCompactionStarted(e event.Event) {
271 // Manual maintenance has one stable SessionOperation card. Keep the legacy
272 // compaction events for automatic passes and older controllers only.
273 if m.maintenance != nil {
274 return
275 }
276 m.finalizeStreamed()
277 m.commitLine(dim(" ⋯ " + i18n.M.CompactionWorking))
278 }
279
280 func (m *chatTUI) ingestCompactionDone(e event.Event) {
281 if m.maintenance != nil {
282 // A legacy controller can emit CompactionDone without SessionOperation.
283 // The optimistic empty-id placeholder uses that event as its terminal
284 // display; identified operations wait for their authoritative record.
285 if m.maintenance.OperationID != "" {
286 return
287 }
288 m.maintenance = nil
289 }
290 // An aborted pass carries no summary; the accompanying Notice (auto) or
291 // compactDoneMsg error (manual) explains why, so don't draw an empty card.
292 if e.Compaction.Summary == "" {
293 return
294 }
295 m.finalizeStreamed()
296 for _, ln := range compactionCardLines(e.Compaction) {
297 m.commitLine(ln)
298 }
299 }
300
301 func (m *chatTUI) ingestSessionOperation(e event.Event) {
302 if e.SessionOperation == nil || e.SessionOperation.OperationID == "" {
303 return
304 }
305 if m.compactCompatibilityPending {
306 m.compactLifecycleObserved = true
307 }
308 incoming := *e.SessionOperation
309 if m.maintenanceTerminal == nil {
310 m.maintenanceTerminal = make(map[string]struct{})
311 }
312 if m.maintenanceLatest == nil {
313 m.maintenanceLatest = make(map[string]event.SessionOperationInfo)
314 }
315 if previous, ok := m.maintenanceLatest[incoming.OperationID]; ok {
316 if previous.RuntimeEpoch != "" && incoming.RuntimeEpoch != "" && previous.RuntimeEpoch != incoming.RuntimeEpoch {
317 return
318 }
319 if previous.OperationRevision > 0 && incoming.OperationRevision > 0 &&
320 incoming.OperationRevision < previous.OperationRevision {
321 return
322 }
323 if sessionOperationTerminal(previous.Status) && !sessionOperationTerminal(incoming.Status) {
324 return
325 }
326 incoming = mergeSessionOperation(previous, incoming)
327 }
328 if _, terminal := m.maintenanceTerminal[incoming.OperationID]; terminal && !sessionOperationTerminal(incoming.Status) {
329 return
330 }
331
332 // One controller admits one maintenance operation at a time. A terminal
333 // event from the prior operation can arrive after the next operation starts;
334 // it may finish its own card but must not replace the active identity.
335 if m.maintenance != nil && m.maintenance.OperationID != "" &&
336 m.maintenance.OperationID != incoming.OperationID {
337 m.maintenanceLatest[incoming.OperationID] = incoming
338 return
339 }
340
341 m.maintenanceLatest[incoming.OperationID] = incoming
342 m.maintenance = &incoming
343 m.renderSessionOperation(&incoming)
344 if sessionOperationTerminal(incoming.Status) {
345 m.maintenanceTerminal[incoming.OperationID] = struct{}{}
346 m.maintenance = nil
347 m.followSessionLease()
348 }
349 }
350
351 func mergeSessionOperation(previous, incoming event.SessionOperationInfo) event.SessionOperationInfo {
352 merged := previous
353 merged.OperationID = incoming.OperationID
354 if incoming.OperationRevision != 0 {
355 merged.OperationRevision = incoming.OperationRevision
356 }
357 if incoming.RuntimeEpoch != "" {
358 merged.RuntimeEpoch = incoming.RuntimeEpoch
359 }
360 if incoming.Kind != "" {
361 merged.Kind = incoming.Kind
362 }
363 if incoming.Activity != "" {
364 merged.Activity = incoming.Activity
365 }
366 if incoming.Status != "" {
367 merged.Status = incoming.Status
368 }
369 if incoming.ErrorCode != "" {
370 merged.ErrorCode = incoming.ErrorCode
371 }
372 if incoming.Detail != "" {
373 merged.Detail = incoming.Detail
374 }
375 merged.Applied = previous.Applied || incoming.Applied
376 if incoming.InputTokens != 0 {
377 merged.InputTokens = incoming.InputTokens
378 }
379 if incoming.ResultTokens != 0 {
380 merged.ResultTokens = incoming.ResultTokens
381 }
382 if incoming.Messages != 0 {
383 merged.Messages = incoming.Messages
384 }
385 if incoming.Summary != "" {
386 merged.Summary = incoming.Summary
387 }
388 if incoming.Archive != "" {
389 merged.Archive = incoming.Archive
390 }
391 return merged
392 }
393
394 func sessionOperationTerminal(status string) bool {
395 switch strings.ToLower(strings.TrimSpace(status)) {
396 case "completed", "noop", "cancelled", "partially_completed", "failed", "interrupted":
397 return true
398 default:
399 return false
400 }
401 }
402
403 func (m *chatTUI) renderSessionOperation(op *event.SessionOperationInfo) {
404 if op == nil || op.OperationID == "" {
405 return
406 }
407 m.finalizeStreamed()
408 line := sessionOperationLine(op)
409 if m.nativeScrollback || m.maintenanceTranscriptID != op.OperationID ||
410 m.maintenanceTranscriptIdx < 0 || m.maintenanceTranscriptIdx >= len(m.transcript) {
411 m.commitSpacer()
412 m.maintenanceTranscriptID = op.OperationID
413 m.maintenanceTranscriptIdx = len(m.transcript)
414 m.commitLine(line)
415 return
416 }
417 m.setTranscriptBlock(m.maintenanceTranscriptIdx, line, transcriptSource{kind: transcriptSourceFixed})
418 m.transcriptDirty = true
419 }
420
421 func sessionOperationLine(op *event.SessionOperationInfo) string {
422 status := strings.ToLower(strings.TrimSpace(op.Status))
423 activity := strings.ToLower(strings.TrimSpace(op.Activity))
424 switch status {
425 case "completed":
426 lines := compactionCardLines(event.Compaction{
427 Trigger: "manual", Messages: op.Messages, Summary: op.Summary, Archive: op.Archive,
428 })
429 if op.InputTokens > 0 || op.ResultTokens > 0 {
430 lines = append(lines, dim(fmt.Sprintf(" │ %s: ~%s → ~%s", i18n.M.CompactionEstimatedTokens,
431 shortTokens(op.InputTokens), shortTokens(op.ResultTokens))))
432 }
433 return strings.Join(lines, "\n")
434 case "noop":
435 return dim(" · " + i18n.M.CompactionNoHistory)
436 case "cancelled":
437 return dim(" ■ " + i18n.M.CompactionStopped)
438 case "partially_completed":
439 return dim(" ■ " + i18n.M.CompactionStoppedPartial)
440 case "failed":
441 return sessionOperationFailureLine(i18n.M.SlashCompactFailed, op.Detail)
442 case "interrupted":
443 return sessionOperationFailureLine(i18n.M.CompactionInterrupted, op.Detail)
444 case "recovery_required":
445 return sessionOperationFailureLine(i18n.M.CompactionRecoveryRequired, op.Detail)
446 }
447 switch activity {
448 case "cancelling":
449 return dim(" ⋯ " + i18n.M.CompactionStopping)
450 case "finalizing":
451 return dim(" ⋯ " + i18n.M.CompactionSaving)
452 case "recovery_required":
453 return sessionOperationFailureLine(i18n.M.CompactionRecoveryRequired, op.Detail)
454 default:
455 return dim(" ⋯ " + i18n.M.CompactionWorking)
456 }
457 }
458
459 func sessionOperationFailureLine(label, detail string) string {
460 detail = strings.TrimSpace(detail)
461 if detail == "" {
462 return " " + red("!") + " " + label
463 }
464 return " " + red("!") + " " + label + ": " + detail
465 }
466
467 func (m *chatTUI) ingestPhase(e event.Event) {
468 m.finalizeStreamed()
469 m.commitLine(fmt.Sprintf("[%s]", e.Text))
470 }
471
472 func (m *chatTUI) ingestApprovalRequest(e event.Event) {
473 // The controller's run goroutine is blocked in the gate awaiting this
474 // decision; the banner shows it in View and key input answers it via
475 // ctrl.Approve. At most one prompt is outstanding, so a field holds it.
476 a := e.Approval
477 m.pendingApproval = &a
478 m.approvalSelection = 0
479 if isRecoveryPlanChangeApproval(&a) {
480 // A plan decision must start neutral: Enter alone cannot make Auto's
481 // strategy/scope choice for the user.
482 m.approvalSelection = -1
483 }
484 }
485
486 func (m *chatTUI) ingestAskRequest(e event.Event) {
487 // The `ask` tool raised a question card; the run goroutine blocks until
488 // ctrl.AnswerQuestion resolves it. Keys drive the card while it's set.
489 m.finalizeStreamed()
490 m.chooser = newChooser(e.Ask)
491 }
492
493 func (m *chatTUI) ingestMCPInteractionRequest(e event.Event) {
494 m.startElicit(e.MCPInteraction)
495 }
496
497 func (m *chatTUI) ingestMCPSurfaceReady(e event.Event) {
498 // Prompts/resources may have arrived after connect; refresh host and
499 // drop the slash catalog so /prompt names reappear without a restart.
500 m.refreshHostAndInvalidateSlashCatalog()
501 m.refreshMCPManager()
502 }
503
504 func (m *chatTUI) ingestTurnDone(e event.Event) {
505 m.readStatusState = readStatusState{}
506 m.clearElicitCard()
507 // The turn settled — freeze anything still streaming, surface a real error,
508 // and gate a plan-mode proposal on the user's approval. Autosave already
509 // happened in Controller, so frontends share the activity-time semantics.
510 m.writeSearchFootnotes()
511 m.commitReasoning()
512 m.commitPending()
513 // The bubble was echoed on Enter and an un-sent turn is swallowed above
514 // (turnDiscarded), so any turn reaching here keeps its bubble in scrollback;
515 // just clear the un-sendable flag.
516 m.confirmBubbleSent()
517 m.state = tuiIdle
518 m.turnPhase = ""
519 m.noteWatchdogIdle()
520 m.queueEditCursor, m.queueEditDraft = -1, ""
521 m.clearSubmittedPastes()
522 m.commitTurnPauseNotice(e)
523 m.commitReceipt(e.Receipt)
524 // Long turns on Windows ConPTY often drop mouse tracking; re-arm on
525 // the next frame so wheel keeps scrolling the transcript (#7583).
526 m.wantMouseReenable = true
527 // Plan-mode approval is now driven by the controller (it emits an
528 // ApprovalRequest when a plan-mode turn produces a proposal), so there's
529 // nothing to detect here.
530 }
531
532 func (m *chatTUI) ingestPreflight(e event.Event) bool {
533 if e.Kind == event.Retrying {
534 m.setRecoveryStatus(e)
535 return true
536 }
537 if e.Kind == event.StreamAttempt {
538 // Clear speculative presentation when an attempt is discarded.
539 if e.StreamAttempt.Action == event.StreamAttemptDiscard {
540 m.toolPartial = ""
541 m.toolTail = nil
542 m.toolStreamIdx = -1
543 m.toolLineCount = 0
544 m.recordRecoveryDiscard(e.StreamAttempt.Reason)
545 }
546 return true
547 }
548 // Any other event means the connection got past the retry window (or the turn
549 // ended), so the transient "retrying" indicator clears.
550 m.clearRecoveryStatus()
551 if m.turnDiscarded {
552 // The turn was un-sent (Esc before any packet); swallow whatever was already
553 // buffered for it until it settles, so nothing lands in scrollback.
554 if e.Kind == event.TurnDone {
555 m.turnDiscarded = false
556 m.state = tuiIdle
557 m.noteWatchdogIdle()
558 }
559 return true
560 }
561 // The first packet of any kind means the server replied — confirm the send so
562 // Esc cancels the stream instead of un-sending. TurnStarted is local (emitted
563 // before the request) and TurnDone is handled in its own case.
564 if e.Kind != event.TurnStarted && e.Kind != event.TurnDone {
565 m.confirmBubbleSent()
566 }
567 return false
568 }
569
569 lines GO