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