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