| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | // TextSink renders a turn's event stream to ANSI text on an io.Writer. It is |
| 14 | // the reference terminal frontend: a headless `reasonix run` writes to stdout, |
| 15 | // and during the cache-first migration the chat TUI is fed through it too. The |
| 16 | // output is byte-for-byte what the agent used to print directly, now driven by |
| 17 | // typed events instead of inline Fprint calls. |
| 18 | // |
| 19 | // renderer, when non-nil, replaces the streamed raw answer text with styled |
| 20 | // markdown once the text stream completes (a Message event). termWidth is the |
| 21 | // column count used to count how many rows the raw stream occupied before the |
| 22 | // redraw moves the cursor back. A nil renderer keeps the raw stream — correct |
| 23 | // for piped output and for the chat TUI, which renders markdown itself. |
| 24 | type TextSink struct { |
| 25 | out io.Writer |
| 26 | renderer Renderer |
| 27 | termWidth int |
| 28 | |
| 29 | // Per-stream state, reset on Message / TurnStarted. |
| 30 | wroteReasoningHeader bool |
| 31 | wroteReasoningBody bool |
| 32 | textWritten bool |
| 33 | showReasoning bool |
| 34 | // Per-turn state, reset on TurnStarted. Tracks whether anything has been |
| 35 | // written this turn so a coordinator Phase marker leads with a blank line |
| 36 | // only when it follows earlier output. |
| 37 | wroteAnything bool |
| 38 | } |
| 39 | |
| 40 | // NewTextSink builds a TextSink writing to out. renderer/termWidth drive the |
| 41 | // post-stream markdown redraw; pass a nil renderer to keep the raw stream. |
| 42 | func NewTextSink(out io.Writer, renderer Renderer, termWidth int) *TextSink { |
| 43 | return &TextSink{out: out, renderer: renderer, termWidth: termWidth} |
| 44 | } |
| 45 | |
| 46 | // SetShowReasoning toggles Claude Code-style verbose display for thinking-mode |
| 47 | // reasoning. Reasoning is still kept in session state by the agent; this only |
| 48 | // controls terminal rendering. |
| 49 | func (s *TextSink) SetShowReasoning(show bool) { s.showReasoning = show } |
| 50 | |
| 51 | // Emit renders one event. Called serially by the run loop. |
| 52 | func (s *TextSink) Emit(e event.Event) { |
| 53 | switch e.Kind { |
| 54 | case event.TurnStarted: |
| 55 | s.wroteReasoningHeader = false |
| 56 | s.wroteReasoningBody = false |
| 57 | s.textWritten = false |
| 58 | s.wroteAnything = false |
| 59 | |
| 60 | case event.Reasoning: |
| 61 | if !s.wroteReasoningHeader { |
| 62 | fmt.Fprintln(s.out, dimText(" ▎ thinking")) |
| 63 | s.wroteReasoningHeader = true |
| 64 | } |
| 65 | if s.showReasoning && e.Text != "" { |
| 66 | fmt.Fprint(s.out, dimText(e.Text)) |
| 67 | s.wroteReasoningBody = true |
| 68 | } |
| 69 | s.wroteAnything = true |
| 70 | |
| 71 | case event.Text: |
| 72 | if s.wroteReasoningHeader && s.wroteReasoningBody && !s.textWritten { |
| 73 | fmt.Fprintln(s.out) // separate the reasoning block from the answer |
| 74 | } |
| 75 | fmt.Fprint(s.out, e.Text) |
| 76 | s.textWritten = true |
| 77 | s.wroteAnything = true |
| 78 | |
| 79 | case event.Message: |
| 80 | s.closeTextStream(e.Text, e.Reasoning) |
| 81 | |
| 82 | case event.ToolDispatch: |
| 83 | // The early (Partial) dispatch carries no args — the full one prints the |
| 84 | // line. A same-ID preview refresh is for upsert-capable frontends; this |
| 85 | // append-only stream ignores it so every tool still prints exactly once. |
| 86 | if e.Tool.Partial || e.Tool.Refreshed { |
| 87 | break |
| 88 | } |
| 89 | fmt.Fprintf(s.out, " -> %s\n", textSinkToolHead(e.Tool.Name, e.Tool.Args)) |
| 90 | s.wroteAnything = true |
| 91 | |
| 92 | case event.ToolResult: |
| 93 | // A successful result is silent (it only feeds the model); a blocked |
| 94 | // call surfaces the same "⊘ name <reason>" line the agent used to print. |
| 95 | if e.Tool.Err != "" { |
| 96 | name := e.Tool.Name |
| 97 | if e.Tool.Name == "use_capability" { |
| 98 | name = textSinkToolHead(e.Tool.Name, e.Tool.Args) |
| 99 | } else if e.Tool.Name == "bash" && e.Tool.Execution != nil && e.Tool.Execution.Shell != "" { |
| 100 | name = e.Tool.Execution.Shell |
| 101 | switch e.Tool.Execution.Shell { |
| 102 | case "powershell": |
| 103 | name = "Windows PowerShell" |
| 104 | case "pwsh": |
| 105 | name = "PowerShell 7+" |
| 106 | case "git-bash": |
| 107 | name = "Git Bash" |
| 108 | } |
| 109 | } |
| 110 | errText := e.Tool.Err |
| 111 | if e.Tool.Execution != nil { |
| 112 | var parts []string |
| 113 | if e.Tool.Execution.ExitCode != nil { |
| 114 | parts = append(parts, fmt.Sprintf("exit %d", *e.Tool.Execution.ExitCode)) |
| 115 | } |
| 116 | if e.Tool.Execution.FailurePhase != "" { |
| 117 | parts = append(parts, e.Tool.Execution.FailurePhase) |
| 118 | } |
| 119 | switch e.Tool.Execution.FailurePhase { |
| 120 | case "preflight", "authorization", "dependency", "launch": |
| 121 | parts = append(parts, "not executed") |
| 122 | default: |
| 123 | if e.Tool.Execution.MutationRisk == "may_be_partial" { |
| 124 | parts = append(parts, "may be partial") |
| 125 | } |
| 126 | } |
| 127 | if len(parts) > 0 { |
| 128 | errText = strings.Join(parts, " · ") + " · " + errText |
| 129 | } |
| 130 | } |
| 131 | fmt.Fprintf(s.out, " ⊘ %s %s\n", name, errText) |
| 132 | s.wroteAnything = true |
| 133 | } |
| 134 | |
| 135 | case event.Usage: |
| 136 | // Close a still-open raw text block before the usage line, matching the |
| 137 | // old Fprintln path for streams that do not emit a Message redraw. |
| 138 | if s.textWritten { |
| 139 | fmt.Fprintln(s.out) |
| 140 | s.textWritten = false |
| 141 | } |
| 142 | s.usageLine(e.Usage, e.Pricing, e.CacheDiagnostics) |
| 143 | |
| 144 | case event.Notice: |
| 145 | glyph := "·" |
| 146 | if e.Level == event.LevelWarn { |
| 147 | glyph = "!" |
| 148 | } |
| 149 | fmt.Fprintf(s.out, " %s %s\n", glyph, e.Text) |
| 150 | s.wroteAnything = true |
| 151 | |
| 152 | case event.Phase: |
| 153 | if s.wroteAnything { |
| 154 | fmt.Fprintln(s.out) |
| 155 | } |
| 156 | fmt.Fprintf(s.out, "[%s]\n", e.Text) |
| 157 | s.wroteAnything = true |
| 158 | |
| 159 | case event.CompactionStarted: |
| 160 | fmt.Fprintln(s.out, dimText(" ⋯ compacting conversation…")) |
| 161 | s.wroteAnything = true |
| 162 | |
| 163 | case event.CompactionDone: |
| 164 | c := e.Compaction |
| 165 | if c.Summary == "" { |
| 166 | break // aborted pass — the caller's Notice already explained why |
| 167 | } |
| 168 | fmt.Fprintln(s.out, dimText(fmt.Sprintf(" ⋯ compacted %d messages (%s)", c.Messages, c.Trigger))) |
| 169 | for _, ln := range strings.Split(strings.TrimRight(c.Summary, "\n"), "\n") { |
| 170 | fmt.Fprintln(s.out, dimText(" "+ln)) |
| 171 | } |
| 172 | s.wroteAnything = true |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func textSinkToolHead(name, args string) string { |
| 177 | if name != "use_capability" { |
| 178 | return name + " " + CompactArgs(args) |
| 179 | } |
| 180 | var call struct { |
| 181 | Action string `json:"action"` |
| 182 | CapabilityID string `json:"capability_id"` |
| 183 | } |
| 184 | if json.Unmarshal([]byte(args), &call) != nil { |
| 185 | return "MCP" |
| 186 | } |
| 187 | subject := strings.TrimSpace(call.CapabilityID) |
| 188 | if subject == "" { |
| 189 | subject = strings.TrimSpace(call.Action) |
| 190 | } |
| 191 | if subject == "" { |
| 192 | return "MCP" |
| 193 | } |
| 194 | return "MCP(" + subject + ")" |
| 195 | } |
| 196 | |
| 197 | // closeTextStream ends the streamed answer. With a renderer wired in and the |
| 198 | // stream short enough to scroll back over, it moves the cursor to where text |
| 199 | // began, clears to end of screen, and re-emits the styled markdown; otherwise |
| 200 | // it just terminates the block with a newline. Reasoning above the text is left |
| 201 | // untouched. Mirrors the old Agent.stream tail exactly. |
| 202 | func (s *TextSink) closeTextStream(text, reasoning string) { |
| 203 | defer func() { |
| 204 | s.wroteReasoningHeader = false |
| 205 | s.wroteReasoningBody = false |
| 206 | s.textWritten = false |
| 207 | }() |
| 208 | if len(text) > 0 { |
| 209 | s.wroteAnything = true |
| 210 | } |
| 211 | if len(text) > 0 && s.renderer != nil { |
| 212 | if moved := streamedRows(text, s.termWidth); moved < 200 { |
| 213 | if moved == 0 { |
| 214 | fmt.Fprint(s.out, "\r\033[0J") |
| 215 | } else { |
| 216 | fmt.Fprintf(s.out, "\r\033[%dA\033[0J", moved) |
| 217 | } |
| 218 | fmt.Fprint(s.out, s.renderer.Render(text)) |
| 219 | return |
| 220 | } |
| 221 | } |
| 222 | if len(text) > 0 || (len(reasoning) > 0 && s.wroteReasoningBody) { |
| 223 | fmt.Fprintln(s.out) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // usageLine writes the one-line token/cache summary; no-op when usage is unset. |
| 228 | func (s *TextSink) usageLine(u *provider.Usage, p *provider.Pricing, d *event.CacheDiagnostics) { |
| 229 | if line := FormatUsageLine(u, p, d); line != "" { |
| 230 | fmt.Fprintln(s.out, line) |
| 231 | s.wroteAnything = true |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // FormatUsageLine renders the per-turn token/cache summary — the key signal for |
| 236 | // the cache-first design — as a single line (no trailing newline), or "" when |
| 237 | // usage is unset or empty. Cache is reported as absolute "(N cached / M new)" |
| 238 | // so a turn that adds a lot of fresh content doesn't read as "cache broke" the |
| 239 | // way a falling percentage would; the cached prefix is still hitting, the |
| 240 | // denominator just grew. Reasoning tokens (a subset of completion) show the |
| 241 | // chain-of-thought cost. Shared by TextSink and the chat TUI so both frontends |
| 242 | // render the line identically. |
| 243 | func FormatUsageLine(u *provider.Usage, p *provider.Pricing, d *event.CacheDiagnostics) string { |
| 244 | if u == nil || u.TotalTokens == 0 { |
| 245 | return "" |
| 246 | } |
| 247 | cacheCol := "" |
| 248 | if u.PromptTokens > 0 { |
| 249 | cached := u.CacheHitTokens |
| 250 | fresh := u.CacheMissTokens |
| 251 | if fresh == 0 { |
| 252 | if d := u.PromptTokens - cached; d > 0 { |
| 253 | fresh = d |
| 254 | } |
| 255 | } |
| 256 | cacheCol = fmt.Sprintf(" (%d cached / %d new)", cached, fresh) |
| 257 | } |
| 258 | reasoning := "" |
| 259 | if u.ReasoningTokens > 0 { |
| 260 | reasoning = fmt.Sprintf(" (%d reasoning)", u.ReasoningTokens) |
| 261 | } |
| 262 | cost := "" |
| 263 | if p != nil { |
| 264 | cost = fmt.Sprintf(" · %s%.4f", p.Symbol(), p.Cost(u)) |
| 265 | } |
| 266 | churn := "" |
| 267 | if d != nil && d.PrefixChanged { |
| 268 | reasons := strings.Join(d.PrefixChangeReasons, "+") |
| 269 | if reasons == "" { |
| 270 | reasons = "unknown" |
| 271 | } |
| 272 | churn = fmt.Sprintf(" · cache prefix changed: %s", reasons) |
| 273 | } |
| 274 | return fmt.Sprintf(" · %d tok · in %d%s · out %d%s%s%s", |
| 275 | u.TotalTokens, u.PromptTokens, cacheCol, u.CompletionTokens, reasoning, cost, churn) |
| 276 | } |
| 277 | |
| 278 | // dimText wraps s in the ANSI dim SGR sequence so reasoning streams visually |
| 279 | // recede from the final answer. |
| 280 | func dimText(s string) string { return "\x1b[2m" + s + "\x1b[0m" } |
| 281 | |
| 282 | // CompactArgs trims and caps a tool's raw JSON arguments for the dispatch line. |
| 283 | // Exported so the CLI can reuse the same rendering without duplicating the logic. |
| 284 | func CompactArgs(s string) string { |
| 285 | s = strings.TrimSpace(s) |
| 286 | r := []rune(s) |
| 287 | if len(r) > 120 { |
| 288 | return string(r[:120]) + "..." |
| 289 | } |
| 290 | return s |
| 291 | } |
| 292 |