返回 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.Phase:
49 m.ingestPhase(e)
50 case event.ApprovalRequest:
51 m.ingestApprovalRequest(e)
52 case event.AskRequest:
53 m.ingestAskRequest(e)
54 case event.MCPInteractionRequest:
55 m.ingestMCPInteractionRequest(e)
56 case event.MCPSurfaceReady:
57 m.ingestMCPSurfaceReady(e)
58 case event.TurnDone:
59 m.ingestTurnDone(e)
60 }
61 }
62
63 func (m *chatTUI) ingestReasoning(e event.Event) {
64 if m.nativeScrollback {
65 if !m.reasoningNative {
66 m.thinkStart = time.Now()
67 m.reasoningNative = true
68 }
69 m.streamReasoning(e.Text)
70 return
71 }
72 if m.reasoningLineIdx < 0 {
73 // Show the marker plus a live text block the moment thinking starts; the
74 // text streams in below it and the block collapses to "thought for Ns"
75 // when it closes (kept expanded only in verbose mode).
76 m.commitSpacer()
77 m.thinkStart = time.Now()
78 m.reasoningLineIdx = len(m.transcript)
79 m.commitLine(dim(" ▎ " + i18n.M.ChatThinking))
80 m.reasoningTextIdx = len(m.transcript)
81 m.commitLine("")
82 m.reasoningView = m.reasoningView[:0]
83 }
84 m.streamReasoning(e.Text)
85 }
86
87 func (m *chatTUI) ingestText(e event.Event) {
88 m.commitReasoningBeforeAnswer()
89 m.pending.WriteString(e.Text)
90 m.streamAnswer()
91 }
92
93 func (m *chatTUI) ingestMessage(e event.Event) {
94 // The answer stream is complete — freeze reasoning + the markdown answer.
95 // Message.Text is the canonical display text (protocol markers already
96 // stripped at emission), so it replaces the raw streamed accumulation.
97 if e.Text != "" && m.pending.Len() > 0 {
98 m.pending.Reset()
99 m.pending.WriteString(e.Text)
100 }
101 m.writeSearchFootnotes()
102 m.commitReasoning()
103 m.commitPending()
104 }
105
106 func (m *chatTUI) ingestToolDispatch(e event.Event) {
107 // The early (partial) dispatch only carries the name — the full dispatch
108 // with args prints the line. Same-ID preview refreshes are ignored because
109 // native scrollback cannot replace an already-printed diff card.
110 if e.Tool.Partial || e.Tool.Refreshed {
111 return
112 }
113 m.finalizeStreamed()
114 switch e.Tool.Name {
115 case "todo_write":
116 // The result decides whether this list becomes canonical; dispatch only
117 // means the model asked for an update.
118 case planApprovalTool:
119 // No longer a tool, but guard anyway: the plan is the assistant's reply.
120 default:
121 m.commitSpacer()
122 if block := diffBlock(e.Tool.Name, e.Tool.Args, e.Tool.FileDiff, m.width, m.diffMaxLines); block != nil {
123 for _, ln := range block {
124 m.commitLine(ln)
125 }
126 return
127 }
128 m.commitTranscriptSource(transcriptSource{
129 kind: transcriptSourceToolCard, raw: e.Tool.Name, aux: e.Tool.Args,
130 })
131 m.beginToolRunning(e.Tool.ID)
132 }
133 }
134
135 func (m *chatTUI) ingestToolProgress(e event.Event) {
136 if event.IsSubagentProgressName(e.Tool.Name) {
137 m.streamSubagentProgress(e.Tool)
138 return
139 }
140 // Unknown names in the reserved namespace may come from a newer agent.
141 // Keep them out of ordinary tool output even though this CLI cannot render
142 // their payload yet.
143 if event.IsReservedSubagentProgressName(e.Tool.Name) {
144 return
145 }
146 m.streamToolOutput(e.Tool.ID, e.Tool.Output)
147 }
148
149 func (m *chatTUI) ingestToolResult(e event.Event) {
150 // A successful result is silent (it only feeds the model); a blocked/failed
151 // call surfaces a red card. Pass the final output so collapseToolOutput has
152 // a last-resort line count when live state was already reset.
153 m.collapseFinalToolOutput(e.Tool)
154 if e.Tool.Name == "todo_write" && e.Tool.Err == "" && e.Tool.TodoWritten {
155 m.todos = append([]event.Todo(nil), e.Tool.Todos...)
156 m.todosDismissed = false
157 }
158 m.rememberSearchResult(e.Tool)
159 if e.Tool.Err != "" {
160 m.finalizeStreamed()
161 label := shellToolDisplayName(e.Tool.Name, e.Tool.Execution)
162 detail := shellFailureDetail(e.Tool.Execution)
163 errText := e.Tool.Err
164 if detail != "" {
165 errText = detail + " · " + errText
166 }
167 m.commitLine(" " + red("●") + " " + bold(label) + " " + red("⊘ "+errText))
168 }
169 }
170
171 func (m *chatTUI) ingestUsage(e event.Event) {
172 if e.Usage != nil {
173 m.turnTokens += e.Usage.CompletionTokens
174 }
175 m.addSessionCostQuote(e.CostQuote)
176 if m.showTurnUsage {
177 if line := renderQuotedTurnReceipt(e.Usage, e.CostQuote, e.CacheDiagnostics); line != "" {
178 m.finalizeStreamed()
179 m.commitSpacer()
180 m.commitTranscriptSource(transcriptSource{kind: transcriptSourceTurnReceipt, raw: line})
181 }
182 }
183 }
184
185 func (m *chatTUI) ingestReadStatus(e event.Event) {
186 m.ingest(e.ReadStatus)
187 }
188
189 func (m *chatTUI) ingestTurnPhase(e event.Event) {
190 // Content-free host phase for the live status line only.
191 if phase := strings.TrimSpace(string(e.PhaseName)); phase != "" {
192 m.turnPhase = phase
193 } else if phase := strings.TrimSpace(e.Text); phase != "" {
194 m.turnPhase = phase
195 }
196 }
197
198 func (m *chatTUI) ingestCompletionSummary(e event.Event) {
199 if e.Completion != nil {
200 if completionSummaryNeedsAttention(e.Completion, "") {
201 m.finalizeStreamed()
202 m.commitLine(fmt.Sprintf(" ! %s", completionSummaryWarning(e.Completion)))
203 }
204 if m.showReasoning {
205 m.finalizeStreamed()
206 m.commitLine(dim(" · " + formatCompletionSummaryLine(e.Completion)))
207 }
208 }
209 }
210
211 func (m *chatTUI) ingestNotice(e event.Event) {
212 glyph := "·"
213 if e.Level == event.LevelWarn {
214 glyph = "!"
215 }
216 m.finalizeStreamed()
217 m.commitLine(fmt.Sprintf(" %s %s", glyph, e.Text))
218 }
219
220 func (m *chatTUI) ingestGuardianAssessment(e event.Event) {
221 m.finalizeStreamed()
222 g := e.Guardian
223 line := fmt.Sprintf("Guardian %s · %s", g.Outcome, g.Tool)
224 if g.Subject != "" {
225 line += " · " + truncateSubject(g.Subject, m.width)
226 }
227 if g.RiskLevel != "" {
228 line += " · risk=" + g.RiskLevel
229 }
230 if g.UserAuthorization != "" {
231 line += " · authorization=" + g.UserAuthorization
232 }
233 if g.Rationale != "" {
234 line += " · " + g.Rationale
235 }
236 if g.Outcome == "deny" {
237 m.commitLine(" ! " + line)
238 } else {
239 m.commitLine(" · " + line)
240 }
241 }
242
243 func (m *chatTUI) ingestExtensionStatus(e event.Event) {
244 // One-line status contribution from an extension sidecar — a
245 // severity-aware notice line, like event.Notice.
246 if line := extensionStatusLine(e.Extension); line != "" {
247 m.finalizeStreamed()
248 m.commitLine(line)
249 }
250 }
251
252 func (m *chatTUI) ingestExtensionSurface(e event.Event) {
253 // A published card/form renders as a transcript card; a notification
254 // renders as a notice line. Form fields themselves arrive through the
255 // Ask machinery (the hub translates them), so no dialog work here.
256 m.finalizeStreamed()
257 if e.Extension != nil && e.Extension.Notification != nil {
258 if line := extensionNotificationLine(e.Extension); line != "" {
259 m.commitLine(line)
260 }
261 return
262 }
263 for _, ln := range extensionSurfaceLines(e.Extension, m.width) {
264 m.commitLine(ln)
265 }
266 }
267
268 func (m *chatTUI) ingestCompactionStarted(e event.Event) {
269 m.finalizeStreamed()
270 m.commitLine(dim(" ⋯ " + i18n.M.CompactionWorking))
271 }
272
273 func (m *chatTUI) ingestCompactionDone(e event.Event) {
274 // An aborted pass carries no summary; the accompanying Notice (auto) or
275 // compactDoneMsg error (manual) explains why, so don't draw an empty card.
276 if e.Compaction.Summary == "" {
277 return
278 }
279 m.finalizeStreamed()
280 for _, ln := range compactionCardLines(e.Compaction) {
281 m.commitLine(ln)
282 }
283 }
284
285 func (m *chatTUI) ingestPhase(e event.Event) {
286 m.finalizeStreamed()
287 m.commitLine(fmt.Sprintf("[%s]", e.Text))
288 }
289
290 func (m *chatTUI) ingestApprovalRequest(e event.Event) {
291 // The controller's run goroutine is blocked in the gate awaiting this
292 // decision; the banner shows it in View and key input answers it via
293 // ctrl.Approve. At most one prompt is outstanding, so a field holds it.
294 a := e.Approval
295 m.pendingApproval = &a
296 m.approvalSelection = 0
297 if isRecoveryPlanChangeApproval(&a) {
298 // A plan decision must start neutral: Enter alone cannot make Auto's
299 // strategy/scope choice for the user.
300 m.approvalSelection = -1
301 }
302 }
303
304 func (m *chatTUI) ingestAskRequest(e event.Event) {
305 // The `ask` tool raised a question card; the run goroutine blocks until
306 // ctrl.AnswerQuestion resolves it. Keys drive the card while it's set.
307 m.finalizeStreamed()
308 m.chooser = newChooser(e.Ask)
309 }
310
311 func (m *chatTUI) ingestMCPInteractionRequest(e event.Event) {
312 m.startElicit(e.MCPInteraction)
313 }
314
315 func (m *chatTUI) ingestMCPSurfaceReady(e event.Event) {
316 // Prompts/resources may have arrived after connect; refresh host and
317 // drop the slash catalog so /prompt names reappear without a restart.
318 m.refreshHostAndInvalidateSlashCatalog()
319 m.refreshMCPManager()
320 }
321
322 func (m *chatTUI) ingestTurnDone(e event.Event) {
323 m.readStatusState = readStatusState{}
324 m.clearElicitCard()
325 // The turn settled — freeze anything still streaming, surface a real error,
326 // and gate a plan-mode proposal on the user's approval. Autosave already
327 // happened in Controller, so frontends share the activity-time semantics.
328 m.writeSearchFootnotes()
329 m.commitReasoning()
330 m.commitPending()
331 // The bubble was echoed on Enter and an un-sent turn is swallowed above
332 // (turnDiscarded), so any turn reaching here keeps its bubble in scrollback;
333 // just clear the un-sendable flag.
334 m.confirmBubbleSent()
335 m.state = tuiIdle
336 m.turnPhase = ""
337 m.noteWatchdogIdle()
338 m.queueEditCursor, m.queueEditDraft = -1, ""
339 m.clearSubmittedPastes()
340 m.commitTurnPauseNotice(e)
341 m.commitReceipt(e.Receipt)
342 // Long turns on Windows ConPTY often drop mouse tracking; re-arm on
343 // the next frame so wheel keeps scrolling the transcript (#7583).
344 m.wantMouseReenable = true
345 // Plan-mode approval is now driven by the controller (it emits an
346 // ApprovalRequest when a plan-mode turn produces a proposal), so there's
347 // nothing to detect here.
348 }
349
350 func (m *chatTUI) ingestPreflight(e event.Event) bool {
351 if e.Kind == event.Retrying {
352 m.setRecoveryStatus(e)
353 return true
354 }
355 if e.Kind == event.StreamAttempt {
356 // Clear speculative presentation when an attempt is discarded.
357 if e.StreamAttempt.Action == event.StreamAttemptDiscard {
358 m.toolPartial = ""
359 m.toolTail = nil
360 m.toolStreamIdx = -1
361 m.toolLineCount = 0
362 m.recordRecoveryDiscard(e.StreamAttempt.Reason)
363 }
364 return true
365 }
366 // Any other event means the connection got past the retry window (or the turn
367 // ended), so the transient "retrying" indicator clears.
368 m.clearRecoveryStatus()
369 if m.turnDiscarded {
370 // The turn was un-sent (Esc before any packet); swallow whatever was already
371 // buffered for it until it settles, so nothing lands in scrollback.
372 if e.Kind == event.TurnDone {
373 m.turnDiscarded = false
374 m.state = tuiIdle
375 m.noteWatchdogIdle()
376 }
377 return true
378 }
379 // The first packet of any kind means the server replied — confirm the send so
380 // Esc cancels the stream instead of un-sending. TurnStarted is local (emitted
381 // before the request) and TurnDone is handled in its own case.
382 if e.Kind != event.TurnStarted && e.Kind != event.TurnDone {
383 m.confirmBubbleSent()
384 }
385 return false
386 }
387
387 lines GO