返回 DeepSeek-Reasonix
status_footer.go
根目录 / internal / cli / status_footer.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/x/ansi"
8
9 "reasonix/internal/billing"
10 "reasonix/internal/event"
11 "reasonix/internal/i18n"
12 "reasonix/internal/provider"
13 )
14
15 const (
16 statusFooterIndent = " "
17 statusFooterGroupGap = 2
18 )
19
20 func footerLabel(label string) string {
21 return themeFg(activeCLITheme.subtle, label)
22 }
23
24 func footerHint(hint string) string {
25 return themeFg(activeCLITheme.subtle, hint)
26 }
27
28 func footerValue(value string) string {
29 return themeFg(activeCLITheme.muted, value)
30 }
31
32 func footerInfo(value string) string {
33 return themeFg(activeCLITheme.info, value)
34 }
35
36 func footerMetric(label, value string) string {
37 if strings.TrimSpace(value) == "" {
38 return ""
39 }
40 return footerLabel(label) + " " + value
41 }
42
43 // renderTurnReceipt attaches the completed turn's token and cost breakdown to
44 // the assistant response. Unlike the persistent footer, this is historical
45 // message metadata: it stays in transcript scrollback and deliberately uses a
46 // quieter palette than runtime/session state.
47 func renderTurnReceipt(u *provider.Usage, p *provider.Pricing, d *event.CacheDiagnostics) string {
48 var quote *billing.CostQuote
49 if u != nil && p != nil {
50 quote = event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: u, Pricing: p}, nil)
51 }
52 return renderQuotedTurnReceipt(u, quote, d)
53 }
54
55 func renderQuotedTurnReceipt(u *provider.Usage, q *billing.CostQuote, d *event.CacheDiagnostics) string {
56 if u == nil || u.TotalTokens == 0 {
57 return ""
58 }
59
60 total := shortTokens(u.TotalTokens) + " tok"
61 if u.Estimated {
62 total = "≈" + total
63 }
64 groups := []string{total}
65 if u.PromptTokens > 0 {
66 cached := u.CacheHitTokens
67 fresh := u.CacheMissTokens
68 if fresh == 0 {
69 fresh = max(u.PromptTokens-cached, 0)
70 }
71 groups = append(groups,
72 "in "+shortTokens(u.PromptTokens),
73 "cached "+shortTokens(cached),
74 "new "+shortTokens(fresh),
75 )
76 }
77 groups = append(groups, "out "+shortTokens(u.CompletionTokens))
78 if u.ReasoningTokens > 0 {
79 groups = append(groups, "reasoning "+shortTokens(u.ReasoningTokens))
80 }
81 if q != nil && q.CostComplete {
82 // Host quotes are estimates; never present a bare zero as real spend.
83 money := q.Original
84 if q.Selected != nil {
85 money = *q.Selected
86 }
87 cost := money.Float64()
88 if cost > 0 {
89 groups = append(groups, fmt.Sprintf("≈%s%.4f", billing.CurrencySymbol(money.Currency), cost))
90 } else {
91 groups = append(groups, "cost n/a")
92 }
93 if band := localizedRateBand(q.RateBand); band != "" {
94 groups = append(groups, band)
95 }
96 }
97 if u.Estimated {
98 groups = append(groups, "estimated")
99 }
100
101 separator := footerHint(" · ")
102 styled := make([]string, 0, len(groups))
103 for _, group := range groups {
104 styled = append(styled, footerValue(group))
105 }
106 receipt := statusFooterIndent + footerLabel(i18n.M.ChatTurnReceiptLabel) + " " + strings.Join(styled, separator)
107 if d != nil && d.PrefixChanged {
108 reasons := strings.Join(d.PrefixChangeReasons, "+")
109 if reasons == "" {
110 reasons = "unknown"
111 }
112 receipt += separator + themeFg(activeCLITheme.warn, "cache prefix changed: "+reasons)
113 }
114 return receipt
115 }
116
117 func localizedRateBand(band string) string {
118 switch band {
119 case billing.RateBandPeak:
120 return i18n.M.RateBandPeak
121 case billing.RateBandOffPeak:
122 return i18n.M.RateBandOffPeak
123 case billing.RateBandMixed:
124 return i18n.M.RateBandMixed
125 default:
126 return ""
127 }
128 }
129
130 func mergeSessionRateBand(current *billing.CostQuote, next billing.CostQuote) string {
131 if current == nil {
132 return next.RateBand
133 }
134 if current.RateBand == "" || next.RateBand == "" {
135 return ""
136 }
137 if current.RateBand == billing.RateBandMixed || next.RateBand == billing.RateBandMixed || current.RateBand != next.RateBand {
138 return billing.RateBandMixed
139 }
140 return current.RateBand
141 }
142
143 func (m *chatTUI) addSessionCostQuote(next *billing.CostQuote) {
144 if m == nil || next == nil {
145 return
146 }
147 band := mergeSessionRateBand(m.sessionCostQuote, *next)
148 quotes := []billing.CostQuote{*next}
149 if m.sessionCostQuote != nil {
150 quotes = append([]billing.CostQuote{*m.sessionCostQuote}, quotes...)
151 }
152 display := ""
153 if next.Selected != nil {
154 display = next.Selected.Currency
155 }
156 total := billing.AggregateQuotes(quotes, display)
157 total.RateBand = band
158 total.RatedAt = ""
159 m.sessionCostQuote = &total
160 }
161
162 func (m chatTUI) sessionCostStatus() string {
163 q := m.sessionCostQuote
164 if q == nil || !q.CostComplete {
165 return ""
166 }
167 money := q.Original
168 if q.Selected != nil {
169 money = *q.Selected
170 }
171 if money.Float64() <= 0 {
172 return ""
173 }
174 value := fmt.Sprintf("≈%s%.4f", billing.CurrencySymbol(money.Currency), money.Float64())
175 if band := localizedRateBand(q.RateBand); band != "" {
176 value += " · " + band
177 }
178 return value
179 }
180
181 // primaryStatusLine renders the interaction half of the first footer row. The
182 // model/profile group is laid out separately so it can stay right-anchored on
183 // wide terminals and move as one unit on narrow terminals.
184 func (m chatTUI) primaryStatusLine(modeTag string, shellMode, cancelRequested bool) string {
185 status := statusFooterIndent + modeTag
186 switch {
187 case m.rewind != nil:
188 status += " · ⟲ rewind"
189 case m.mcpImport != nil:
190 status += " · MCP import"
191 case m.resumePick != nil:
192 status += " · " + i18n.M.StatusResumePicker
193 case m.quickPick != nil:
194 status += " · " + m.quickPick.title
195 case m.mcp != nil:
196 status += " · MCP"
197 case m.skillPick != nil:
198 status += " · " + i18n.M.SkillPickerStatusLabel
199 case m.chooser != nil:
200 status += " · " + i18n.M.ChatStatusQuestion
201 case m.pendingApproval != nil && m.pendingApproval.Tool == planApprovalTool:
202 status += " · " + i18n.M.ChatStatusPlanApproval
203 case m.pendingApproval != nil:
204 status += " · " + i18n.M.ChatStatusToolApproval
205 case m.clipboardImagePending:
206 status += " · " + yellow(i18n.M.ClipboardImagePastingHint)
207 case m.copyNoticeText != "":
208 status += " · " + green(m.copyNoticeText)
209 case m.maintenance != nil:
210 status += " · " + footerValue(i18n.M.CompactionWorking)
211 case cancelRequested:
212 status += " · " + i18n.M.CtrlCQuitHint
213 case shellMode:
214 status += " · " + i18n.M.ShellModeHint
215 case m.ctrl != nil && m.ctrl.AutoApproveTools():
216 status += " · " + footerHint(i18n.M.ChatStatusCycleHintCompact)
217 default:
218 status += " · " + footerValue(i18n.M.ChatStatusIdle) + " · " + footerHint(i18n.M.ChatStatusCycleHintCompact)
219 }
220 if mt := m.mouseTag(); mt != "" {
221 status += " · " + mt
222 }
223 return status
224 }
225
226 // presetTag is retained for the stable footer composition contract. Retired
227 // role settings have no status-line representation.
228 func (m chatTUI) presetTag() string {
229 return ""
230 }
231
232 // statusModelWorkGroup is the bounded, session-level group placed at the right
233 // edge of the first footer row. A custom statusline still replaces every
234 // built-in data field, matching its existing configuration contract.
235 func (m chatTUI) statusModelWorkGroup(maxWidth int) string {
236 if m.statuslineCmd != "" && m.statuslineOut != "" {
237 return ""
238 }
239 model := strings.TrimSpace(m.label)
240 if maxWidth <= 0 {
241 maxWidth = 1
242 }
243
244 const separator = " "
245 tail := make([]string, 0, 3)
246 if effort := m.effortTag(); effort != "" {
247 tail = append(tail, effort)
248 }
249 if preset := m.presetTag(); preset != "" {
250 tail = append(tail, preset)
251 }
252 if model == "" && len(tail) == 0 {
253 return ""
254 }
255
256 fields := append([]string(nil), tail...)
257 if model != "" {
258 fields = append([]string{footerMetric(i18n.M.ChatStatusModelLabel, footerInfo(model))}, fields...)
259 }
260 full := strings.Join(fields, separator)
261 if visibleWidth(full) <= maxWidth {
262 return full
263 }
264
265 // Model names own the flexible slot. Keep effort and work intact while they
266 // fit, and compact only the model before falling back to a bounded plain group.
267 if model != "" {
268 tailWidth := visibleWidth(strings.Join(tail, separator))
269 if len(tail) > 0 {
270 tailWidth += visibleWidth(separator)
271 }
272 modelBudget := maxWidth - tailWidth - visibleWidth(i18n.M.ChatStatusModelLabel+" ")
273 if modelBudget >= 4 {
274 modelField := footerMetric(i18n.M.ChatStatusModelLabel, footerInfo(compactMiddle(model, modelBudget)))
275 if len(tail) == 0 {
276 return modelField
277 }
278 return modelField + separator + strings.Join(tail, separator)
279 }
280 }
281 return footerHint(compactMiddle(ansi.Strip(full), maxWidth))
282 }
283
284 func cacheStatusColor(rate float64) cliColor {
285 switch {
286 case rate >= 80:
287 return activeCLITheme.success
288 case rate >= 50:
289 return activeCLITheme.info
290 default:
291 return activeCLITheme.warn
292 }
293 }
294
295 func renderContextStatusGroups(used, window int, ratio float64) []string {
296 if used == 0 || window == 0 {
297 return nil
298 }
299 pct := used * 100 / window
300 ctxValue := fmt.Sprintf("%s (%d%%)", shortTokens(used), pct)
301
302 if ratio <= 0 || ratio >= 1 {
303 ctxValue = fmt.Sprintf("%s / %s (%d%%)", shortTokens(used), shortTokens(window), pct)
304 color := activeCLITheme.muted
305 switch {
306 case pct >= 85:
307 color = activeCLITheme.danger
308 case pct >= 60:
309 color = activeCLITheme.warn
310 }
311 return []string{footerMetric(i18n.M.ChatStatusContextLabel, themeFg(color, ctxValue))}
312 }
313
314 threshold := int(ratio * 100)
315 left := max(threshold-pct, 0)
316 ctxColor := activeCLITheme.muted
317 compactColor := activeCLITheme.muted
318 switch {
319 case pct >= threshold:
320 // Preserve two levels of urgency from the selected design: context is a
321 // warning, while the exhausted compaction headroom is the actual danger.
322 ctxColor = activeCLITheme.warn
323 compactColor = activeCLITheme.danger
324 case left <= 10:
325 ctxColor = activeCLITheme.warn
326 compactColor = activeCLITheme.warn
327 }
328 return []string{
329 footerMetric(i18n.M.ChatStatusContextLabel, themeFg(ctxColor, ctxValue)),
330 footerMetric(i18n.M.ChatStatusCompactLabel, themeFg(compactColor, fmt.Sprintf("%d%%", left))),
331 }
332 }
333
334 // statusTelemetryGroups returns independently placeable session metrics. Git is
335 // intentionally excluded because it owns the flexible identity slot; keeping
336 // metrics separate lets narrow layouts wrap only between semantic groups.
337 func (m chatTUI) statusTelemetryGroups() []string {
338 if m.statuslineCmd != "" && m.statuslineOut != "" {
339 return []string{m.statuslineOut}
340 }
341 var data []string
342 if m.ctrl != nil {
343 if body, rate, ok := m.cacheStatus(); ok {
344 data = append(data, footerMetric(i18n.M.ChatStatusCacheLabel, themeFg(cacheStatusColor(rate), body)))
345 }
346 used, window := m.ctrl.ContextSnapshot()
347 data = append(data, renderContextStatusGroups(used, window, m.ctrl.CompactRatio())...)
348 if jt := m.jobsTag(); jt != "" {
349 data = append(data, footerMetric(i18n.M.ChatStatusJobsLabel, footerInfo(ansi.Strip(jt))))
350 }
351 }
352 if m.balance != "" {
353 data = append(data, footerMetric(i18n.M.ChatStatusBalanceLabel, footerValue(m.balance)))
354 }
355 if cost := m.sessionCostStatus(); cost != "" {
356 data = append(data, footerMetric(i18n.M.ChatStatusCostLabel, footerValue(cost)))
357 }
358 return data
359 }
360
361 // renderStatusBlock owns the complete persistent footer layout. The optional
362 // data band is separated from interaction state when Git or telemetry exists;
363 // narrow screens add deliberate left-aligned rows only between semantic groups.
364 func (m chatTUI) renderStatusBlock(primary string, width int) string {
365 if width <= 0 {
366 width = 1
367 }
368 primary = hideStatusHintWhenKeyNamesCannotFit(primary, width)
369 modelWork := m.statusModelWorkGroup(max(width-visibleWidth(statusFooterIndent), 1))
370 first := layoutStatusSides(primary, modelWork, width)
371 second := m.layoutGitTelemetry(width)
372 if second == "" {
373 return first
374 }
375 return first + "\n" + statusFooterDivider(width) + "\n" + second
376 }
377
378 // hideStatusHintWhenKeyNamesCannotFit keeps the readable Shift+Tab/Ctrl+Y
379 // spelling on normal terminals without hard-wrapping a single shortcut on an
380 // extremely narrow terminal. In that case the idle state remains visible and
381 // the optional shortcut help yields space to the composer.
382 func hideStatusHintWhenKeyNamesCannotFit(primary string, width int) string {
383 hint := i18n.M.ChatStatusCycleHintCompact
384 for group := range strings.SplitSeq(hint, " · ") {
385 if visibleWidth(statusFooterIndent+group) > width {
386 return strings.Replace(primary, " · "+footerHint(hint), "", 1)
387 }
388 }
389 return primary
390 }
391
392 func statusFooterDivider(width int) string {
393 width = max(width, 1)
394 if width <= visibleWidth(statusFooterIndent) {
395 return themeFg(activeCLITheme.border, strings.Repeat("─", width))
396 }
397 ruleWidth := width - visibleWidth(statusFooterIndent)
398 return statusFooterIndent + themeFg(activeCLITheme.border, strings.Repeat("─", ruleWidth))
399 }
400
401 func layoutStatusSides(left, right string, width int) string {
402 switch {
403 case right == "":
404 return wrapStatusGroups(left, width)
405 case left == "":
406 return rightAlignStatusGroup(right, width)
407 }
408 leftWidth := visibleWidth(left)
409 rightWidth := visibleWidth(right)
410 if leftWidth+statusFooterGroupGap+rightWidth <= width {
411 return left + strings.Repeat(" ", width-leftWidth-rightWidth) + right
412 }
413 // Once the two semantic halves no longer fit, switch layout deliberately:
414 // interaction groups wrap only at their separators, while model/work owns a
415 // new left-aligned row. This avoids the floating right-side orphan seen when
416 // a terminal crosses the medium-width breakpoint.
417 return wrapStatusGroups(left, width) + "\n" + statusFooterIndent + right
418 }
419
420 func wrapStatusGroups(line string, width int) string {
421 if width <= 0 || line == "" || visibleWidth(line) <= width {
422 return line
423 }
424 groups := strings.Split(line, " · ")
425 if len(groups) < 2 {
426 return wrapStatusLine(line, width)
427 }
428
429 var rows []string
430 current := groups[0]
431 for _, group := range groups[1:] {
432 candidate := current + " · " + group
433 if visibleWidth(candidate) <= width {
434 current = candidate
435 continue
436 }
437 rows = append(rows, wrapStatusLine(current, width))
438 current = statusFooterIndent + group
439 }
440 rows = append(rows, wrapStatusLine(current, width))
441 return strings.Join(rows, "\n")
442 }
443
444 func rightAlignStatusGroup(group string, width int) string {
445 if group == "" {
446 return ""
447 }
448 if visibleWidth(group) <= width {
449 return strings.Repeat(" ", width-visibleWidth(group)) + group
450 }
451 return wrapStatusLine(group, width)
452 }
453
454 func (m chatTUI) layoutGitTelemetry(width int) string {
455 telemetryGroups := m.statusTelemetryGroups()
456 telemetry := strings.Join(telemetryGroups, " ")
457 hasGit := strings.TrimSpace(m.gitStatus.Repo) != "" && strings.TrimSpace(m.gitStatus.Branch) != ""
458 if !hasGit {
459 // Without a Git identity there is no left-hand peer to balance. Keep the
460 // telemetry anchored to the normal footer indent instead of leaving a
461 // repo-sized visual hole across most of a wide terminal.
462 return packStatusGroups(telemetryGroups, width)
463 }
464
465 fullGitBudget := max(width-visibleWidth(statusFooterIndent), 1)
466 git := m.gitStatus.RenderWithin(fullGitBudget, activeCLITheme.warn)
467 gitLine := statusFooterIndent + git
468 if telemetry == "" {
469 return gitLine
470 }
471
472 telemetryWidth := visibleWidth(telemetry)
473 if visibleWidth(gitLine)+statusFooterGroupGap+telemetryWidth <= width {
474 return gitLine + strings.Repeat(" ", width-visibleWidth(gitLine)-telemetryWidth) + telemetry
475 }
476
477 // Under width pressure Git gets its own full row instead of being shortened
478 // merely to keep telemetry beside it. Telemetry then packs left-to-right by
479 // semantic group, so no right-aligned fragment floats on a continuation row.
480 return gitLine + "\n" + packStatusGroups(telemetryGroups, width)
481 }
482
483 func packStatusGroups(groups []string, width int) string {
484 width = max(width, 1)
485 if len(groups) == 0 {
486 return ""
487 }
488 indent := statusFooterIndent
489 if width <= visibleWidth(indent) {
490 indent = ""
491 }
492
493 var rows []string
494 current := indent
495 for _, group := range groups {
496 if strings.TrimSpace(ansi.Strip(group)) == "" {
497 continue
498 }
499 candidate := current + group
500 if strings.TrimSpace(ansi.Strip(current)) != "" {
501 candidate = current + " " + group
502 }
503 if visibleWidth(candidate) <= width {
504 current = candidate
505 continue
506 }
507 if strings.TrimSpace(ansi.Strip(current)) != "" {
508 rows = append(rows, current)
509 }
510 current = indent + group
511 if visibleWidth(current) > width {
512 rows = append(rows, wrapStatusLine(current, width))
513 current = indent
514 }
515 }
516 if strings.TrimSpace(ansi.Strip(current)) != "" {
517 rows = append(rows, current)
518 }
519 return strings.Join(rows, "\n")
520 }
521
521 lines GO