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