返回 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 cancelRequested:
210 status += " · " + i18n.M.CtrlCQuitHint
211 case shellMode:
212 status += " · " + i18n.M.ShellModeHint
213 case m.ctrl != nil && m.ctrl.AutoApproveTools():
214 status += " · " + footerHint(i18n.M.ChatStatusCycleHintCompact)
215 default:
216 status += " · " + footerValue(i18n.M.ChatStatusIdle) + " · " + footerHint(i18n.M.ChatStatusCycleHintCompact)
217 }
218 if mt := m.mouseTag(); mt != "" {
219 status += " · " + mt
220 }
221 return status
222 }
223
224 // presetTag is retained for the stable footer composition contract. Retired
225 // role settings have no status-line representation.
226 func (m chatTUI) presetTag() string {
227 return ""
228 }
229
230 // statusModelWorkGroup is the bounded, session-level group placed at the right
231 // edge of the first footer row. A custom statusline still replaces every
232 // built-in data field, matching its existing configuration contract.
233 func (m chatTUI) statusModelWorkGroup(maxWidth int) string {
234 if m.statuslineCmd != "" && m.statuslineOut != "" {
235 return ""
236 }
237 model := strings.TrimSpace(m.label)
238 if maxWidth <= 0 {
239 maxWidth = 1
240 }
241
242 const separator = " "
243 tail := make([]string, 0, 3)
244 if effort := m.effortTag(); effort != "" {
245 tail = append(tail, effort)
246 }
247 if preset := m.presetTag(); preset != "" {
248 tail = append(tail, preset)
249 }
250 if model == "" && len(tail) == 0 {
251 return ""
252 }
253
254 fields := append([]string(nil), tail...)
255 if model != "" {
256 fields = append([]string{footerMetric(i18n.M.ChatStatusModelLabel, footerInfo(model))}, fields...)
257 }
258 full := strings.Join(fields, separator)
259 if visibleWidth(full) <= maxWidth {
260 return full
261 }
262
263 // Model names own the flexible slot. Keep effort and work intact while they
264 // fit, and compact only the model before falling back to a bounded plain group.
265 if model != "" {
266 tailWidth := visibleWidth(strings.Join(tail, separator))
267 if len(tail) > 0 {
268 tailWidth += visibleWidth(separator)
269 }
270 modelBudget := maxWidth - tailWidth - visibleWidth(i18n.M.ChatStatusModelLabel+" ")
271 if modelBudget >= 4 {
272 modelField := footerMetric(i18n.M.ChatStatusModelLabel, footerInfo(compactMiddle(model, modelBudget)))
273 if len(tail) == 0 {
274 return modelField
275 }
276 return modelField + separator + strings.Join(tail, separator)
277 }
278 }
279 return footerHint(compactMiddle(ansi.Strip(full), maxWidth))
280 }
281
282 func cacheStatusColor(rate float64) cliColor {
283 switch {
284 case rate >= 80:
285 return activeCLITheme.success
286 case rate >= 50:
287 return activeCLITheme.info
288 default:
289 return activeCLITheme.warn
290 }
291 }
292
293 func renderContextStatusGroups(used, window int, ratio float64) []string {
294 if used == 0 || window == 0 {
295 return nil
296 }
297 pct := used * 100 / window
298 ctxValue := fmt.Sprintf("%s (%d%%)", shortTokens(used), pct)
299
300 if ratio <= 0 || ratio >= 1 {
301 ctxValue = fmt.Sprintf("%s / %s (%d%%)", shortTokens(used), shortTokens(window), pct)
302 color := activeCLITheme.muted
303 switch {
304 case pct >= 85:
305 color = activeCLITheme.danger
306 case pct >= 60:
307 color = activeCLITheme.warn
308 }
309 return []string{footerMetric(i18n.M.ChatStatusContextLabel, themeFg(color, ctxValue))}
310 }
311
312 threshold := int(ratio * 100)
313 left := max(threshold-pct, 0)
314 ctxColor := activeCLITheme.muted
315 compactColor := activeCLITheme.muted
316 switch {
317 case pct >= threshold:
318 // Preserve two levels of urgency from the selected design: context is a
319 // warning, while the exhausted compaction headroom is the actual danger.
320 ctxColor = activeCLITheme.warn
321 compactColor = activeCLITheme.danger
322 case left <= 10:
323 ctxColor = activeCLITheme.warn
324 compactColor = activeCLITheme.warn
325 }
326 return []string{
327 footerMetric(i18n.M.ChatStatusContextLabel, themeFg(ctxColor, ctxValue)),
328 footerMetric(i18n.M.ChatStatusCompactLabel, themeFg(compactColor, fmt.Sprintf("%d%%", left))),
329 }
330 }
331
332 // statusTelemetryGroups returns independently placeable session metrics. Git is
333 // intentionally excluded because it owns the flexible identity slot; keeping
334 // metrics separate lets narrow layouts wrap only between semantic groups.
335 func (m chatTUI) statusTelemetryGroups() []string {
336 if m.statuslineCmd != "" && m.statuslineOut != "" {
337 return []string{m.statuslineOut}
338 }
339 var data []string
340 if m.ctrl != nil {
341 if body, rate, ok := m.cacheStatus(); ok {
342 data = append(data, footerMetric(i18n.M.ChatStatusCacheLabel, themeFg(cacheStatusColor(rate), body)))
343 }
344 used, window := m.ctrl.ContextSnapshot()
345 data = append(data, renderContextStatusGroups(used, window, m.ctrl.CompactRatio())...)
346 if jt := m.jobsTag(); jt != "" {
347 data = append(data, footerMetric(i18n.M.ChatStatusJobsLabel, footerInfo(ansi.Strip(jt))))
348 }
349 }
350 if m.balance != "" {
351 data = append(data, footerMetric(i18n.M.ChatStatusBalanceLabel, footerValue(m.balance)))
352 }
353 if cost := m.sessionCostStatus(); cost != "" {
354 data = append(data, footerMetric(i18n.M.ChatStatusCostLabel, footerValue(cost)))
355 }
356 return data
357 }
358
359 // renderStatusBlock owns the complete persistent footer layout. The optional
360 // data band is separated from interaction state when Git or telemetry exists;
361 // narrow screens add deliberate left-aligned rows only between semantic groups.
362 func (m chatTUI) renderStatusBlock(primary string, width int) string {
363 if width <= 0 {
364 width = 1
365 }
366 primary = hideStatusHintWhenKeyNamesCannotFit(primary, width)
367 modelWork := m.statusModelWorkGroup(max(width-visibleWidth(statusFooterIndent), 1))
368 first := layoutStatusSides(primary, modelWork, width)
369 second := m.layoutGitTelemetry(width)
370 if second == "" {
371 return first
372 }
373 return first + "\n" + statusFooterDivider(width) + "\n" + second
374 }
375
376 // hideStatusHintWhenKeyNamesCannotFit keeps the readable Shift+Tab/Ctrl+Y
377 // spelling on normal terminals without hard-wrapping a single shortcut on an
378 // extremely narrow terminal. In that case the idle state remains visible and
379 // the optional shortcut help yields space to the composer.
380 func hideStatusHintWhenKeyNamesCannotFit(primary string, width int) string {
381 hint := i18n.M.ChatStatusCycleHintCompact
382 for group := range strings.SplitSeq(hint, " · ") {
383 if visibleWidth(statusFooterIndent+group) > width {
384 return strings.Replace(primary, " · "+footerHint(hint), "", 1)
385 }
386 }
387 return primary
388 }
389
390 func statusFooterDivider(width int) string {
391 width = max(width, 1)
392 if width <= visibleWidth(statusFooterIndent) {
393 return themeFg(activeCLITheme.border, strings.Repeat("─", width))
394 }
395 ruleWidth := width - visibleWidth(statusFooterIndent)
396 return statusFooterIndent + themeFg(activeCLITheme.border, strings.Repeat("─", ruleWidth))
397 }
398
399 func layoutStatusSides(left, right string, width int) string {
400 switch {
401 case right == "":
402 return wrapStatusGroups(left, width)
403 case left == "":
404 return rightAlignStatusGroup(right, width)
405 }
406 leftWidth := visibleWidth(left)
407 rightWidth := visibleWidth(right)
408 if leftWidth+statusFooterGroupGap+rightWidth <= width {
409 return left + strings.Repeat(" ", width-leftWidth-rightWidth) + right
410 }
411 // Once the two semantic halves no longer fit, switch layout deliberately:
412 // interaction groups wrap only at their separators, while model/work owns a
413 // new left-aligned row. This avoids the floating right-side orphan seen when
414 // a terminal crosses the medium-width breakpoint.
415 return wrapStatusGroups(left, width) + "\n" + statusFooterIndent + right
416 }
417
418 func wrapStatusGroups(line string, width int) string {
419 if width <= 0 || line == "" || visibleWidth(line) <= width {
420 return line
421 }
422 groups := strings.Split(line, " · ")
423 if len(groups) < 2 {
424 return wrapStatusLine(line, width)
425 }
426
427 var rows []string
428 current := groups[0]
429 for _, group := range groups[1:] {
430 candidate := current + " · " + group
431 if visibleWidth(candidate) <= width {
432 current = candidate
433 continue
434 }
435 rows = append(rows, wrapStatusLine(current, width))
436 current = statusFooterIndent + group
437 }
438 rows = append(rows, wrapStatusLine(current, width))
439 return strings.Join(rows, "\n")
440 }
441
442 func rightAlignStatusGroup(group string, width int) string {
443 if group == "" {
444 return ""
445 }
446 if visibleWidth(group) <= width {
447 return strings.Repeat(" ", width-visibleWidth(group)) + group
448 }
449 return wrapStatusLine(group, width)
450 }
451
452 func (m chatTUI) layoutGitTelemetry(width int) string {
453 telemetryGroups := m.statusTelemetryGroups()
454 telemetry := strings.Join(telemetryGroups, " ")
455 hasGit := strings.TrimSpace(m.gitStatus.Repo) != "" && strings.TrimSpace(m.gitStatus.Branch) != ""
456 if !hasGit {
457 // Without a Git identity there is no left-hand peer to balance. Keep the
458 // telemetry anchored to the normal footer indent instead of leaving a
459 // repo-sized visual hole across most of a wide terminal.
460 return packStatusGroups(telemetryGroups, width)
461 }
462
463 fullGitBudget := max(width-visibleWidth(statusFooterIndent), 1)
464 git := m.gitStatus.RenderWithin(fullGitBudget, activeCLITheme.warn)
465 gitLine := statusFooterIndent + git
466 if telemetry == "" {
467 return gitLine
468 }
469
470 telemetryWidth := visibleWidth(telemetry)
471 if visibleWidth(gitLine)+statusFooterGroupGap+telemetryWidth <= width {
472 return gitLine + strings.Repeat(" ", width-visibleWidth(gitLine)-telemetryWidth) + telemetry
473 }
474
475 // Under width pressure Git gets its own full row instead of being shortened
476 // merely to keep telemetry beside it. Telemetry then packs left-to-right by
477 // semantic group, so no right-aligned fragment floats on a continuation row.
478 return gitLine + "\n" + packStatusGroups(telemetryGroups, width)
479 }
480
481 func packStatusGroups(groups []string, width int) string {
482 width = max(width, 1)
483 if len(groups) == 0 {
484 return ""
485 }
486 indent := statusFooterIndent
487 if width <= visibleWidth(indent) {
488 indent = ""
489 }
490
491 var rows []string
492 current := indent
493 for _, group := range groups {
494 if strings.TrimSpace(ansi.Strip(group)) == "" {
495 continue
496 }
497 candidate := current + group
498 if strings.TrimSpace(ansi.Strip(current)) != "" {
499 candidate = current + " " + group
500 }
501 if visibleWidth(candidate) <= width {
502 current = candidate
503 continue
504 }
505 if strings.TrimSpace(ansi.Strip(current)) != "" {
506 rows = append(rows, current)
507 }
508 current = indent + group
509 if visibleWidth(current) > width {
510 rows = append(rows, wrapStatusLine(current, width))
511 current = indent
512 }
513 }
514 if strings.TrimSpace(ansi.Strip(current)) != "" {
515 rows = append(rows, current)
516 }
517 return strings.Join(rows, "\n")
518 }
519
519 lines GO