| 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 |