返回 DeepSeek-Reasonix
receipt_card.go
根目录 / internal / cli / receipt_card.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/event"
8 "reasonix/internal/i18n"
9 )
10
11 // maxReceiptGapLines bounds the card. A receipt long enough to scroll is a
12 // receipt nobody reads, and the count tail keeps the total honest.
13 const maxReceiptGapLines = 5
14
15 // renderReceiptCard turns the receipt into scrollback lines. The clean case
16 // gets one quiet line: the user just watched the tools run, so repeating the
17 // work back is noise. What no transcript carries is the absence, and that is
18 // what the card spends its lines on.
19 func renderReceiptCard(r *event.CompletionReceipt, width int) []string {
20 if r == nil {
21 return nil
22 }
23 gaps := receiptGapLines(r, width)
24 if len(gaps) == 0 {
25 if r.Verdict != "done" {
26 return nil
27 }
28 return []string{wrapForViewport(" ✓ "+i18n.M.ReceiptVerified+receiptEvidenceTail(r), width, activeCLITheme.muted)}
29 }
30 lines := []string{wrapForViewport(" ⚠ "+i18n.M.ReceiptGapsHeader, width, activeCLITheme.warn)}
31 shown := min(len(gaps), maxReceiptGapLines)
32 for _, gap := range gaps[:shown] {
33 lines = append(lines, wrapForViewport(" "+gap, width, activeCLITheme.muted))
34 }
35 if rest := len(gaps) - shown; rest > 0 {
36 lines = append(lines, wrapForViewport(" "+fmt.Sprintf(i18n.M.ReceiptMore, rest), width, activeCLITheme.muted))
37 }
38 if len(r.Risks) > 0 {
39 lines = append(lines, wrapForViewport(" · "+i18n.M.ReceiptRisksHeader, width, activeCLITheme.muted))
40 for _, risk := range r.Risks {
41 lines = append(lines, wrapForViewport(" "+risk, width, activeCLITheme.muted))
42 }
43 }
44 return lines
45 }
46
47 // receiptGapLines renders each gap as "<phrase>: <detail>", falling back to the
48 // raw kind when a catalogue has no phrase for it — an unknown kind must still
49 // be shown, because silently dropping one is the failure this card exists to
50 // prevent.
51 func receiptGapLines(r *event.CompletionReceipt, width int) []string {
52 out := make([]string, 0, len(r.Gaps))
53 for _, gap := range r.Gaps {
54 phrase := i18n.M.ReceiptGapKinds[gap.Kind]
55 if phrase == "" {
56 phrase = gap.Kind
57 }
58 if detail := receiptDetail(gap.Detail); detail != "" {
59 phrase += ": " + detail
60 }
61 out = append(out, clipToLine(phrase, width))
62 }
63 return out
64 }
65
66 // receiptEvidenceTail names what carried the clean verdict, so "verified" is
67 // never an unsourced assertion.
68 func receiptEvidenceTail(r *event.CompletionReceipt) string {
69 var parts []string
70 if n := len(r.Changes); n > 0 {
71 parts = append(parts, fmt.Sprintf("%d changed", n))
72 }
73 for _, v := range r.Verifications {
74 if v.Passed && !v.Stale {
75 parts = append(parts, v.Command)
76 }
77 }
78 if len(parts) == 0 {
79 return ""
80 }
81 return " · " + strings.Join(parts, " · ")
82 }
83
84 // commitReceipt appends the card to the transcript.
85 func (m *chatTUI) commitReceipt(r *event.CompletionReceipt) {
86 for _, line := range renderReceiptCard(r, m.width) {
87 m.commitLine(line)
88 }
89 }
90
91 // receiptGapIndent is the visual indent every gap line carries; the budget
92 // below is what remains of the row after it.
93 const receiptGapIndent = 6
94
95 // clipToLine keeps one gap on one row. The phrase length varies by catalogue,
96 // so the budget is the row, not the detail: a per-detail cap that fits English
97 // overflows the moment a longer translation prefixes it.
98 func clipToLine(s string, width int) string {
99 budget := max(width-receiptGapIndent-2, 24)
100 runes := []rune(s)
101 if len(runes) <= budget {
102 return s
103 }
104 return strings.TrimSpace(string(runes[:budget])) + "…"
105 }
106
107 // receiptDetail drops a leading `cd <path> &&`: the run is already there, so
108 // the command itself is the part that identifies it.
109 func receiptDetail(detail string) string {
110 detail = strings.TrimSpace(detail)
111 if rest, ok := strings.CutPrefix(detail, "cd "); ok {
112 if _, after, found := strings.Cut(rest, " && "); found {
113 detail = strings.TrimSpace(after)
114 }
115 }
116 return detail
117 }
118
118 lines GO