返回 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/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
292 lines GO