-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathagents_render.go
More file actions
1251 lines (1170 loc) · 39.8 KB
/
agents_render.go
File metadata and controls
1251 lines (1170 loc) · 39.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package cli
import (
"bytes"
"cmp"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/coder/coder/v2/codersdk"
)
const (
contextCompactionToolName = "context_compaction"
toolBlockIndent = " "
toolDetailIndent = " "
toolSummaryFallbackWidth = 48
pendingToolIcon = "○"
reasoningPrefix = "thinking: "
)
func compactTranscriptJSON(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
var builder bytes.Buffer
if err := json.Compact(&builder, raw); err == nil {
return builder.String()
}
return string(raw)
}
func toolBaseName(name string) string {
name = strings.TrimSpace(name)
name = strings.TrimPrefix(name, "coder_")
name = strings.TrimPrefix(name, "github__")
return strings.Join(strings.Fields(name), " ")
}
func humanizeToolName(name string) string {
name = strings.ReplaceAll(toolBaseName(name), "_", " ")
name = strings.Join(strings.Fields(name), " ")
if name == "" {
return "tool"
}
return name
}
func normalizeToolName(name string) string {
if toolBaseName(name) == "" {
return ""
}
return strings.ReplaceAll(strings.ToLower(humanizeToolName(name)), " ", "_")
}
func summarizeToolContent(toolName, raw string, fields ...string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var parsed any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
if summary := toolObjectSummary(toolName, parsed); summary != "" {
return summary
}
if value := firstStringField(parsed, fields...); value != "" {
return strconv.Quote(value)
}
if value := firstShortStringValue(parsed); value != "" {
return strconv.Quote(value)
}
}
compact := compactTranscriptJSON(json.RawMessage(raw))
if compact == "" {
return ""
}
compactRunes := []rune(compact)
if len(compactRunes) <= toolSummaryFallbackWidth {
return compact
}
return string(compactRunes[:toolSummaryFallbackWidth-1]) + "…"
}
var toolArgsSummary = summarizeToolContent
func toolResultSummary(toolName, argsJSON, resultJSON string) string {
return cmp.Or(
summarizeToolContent(toolName, argsJSON),
summarizeToolContent(toolName, resultJSON),
"null",
)
}
func toolObjectSummary(toolName string, parsed any) string {
normalized := normalizeToolName(toolName)
switch {
case normalized == "execute" || normalized == "execute_command" || normalized == "run_command":
if command := firstStringField(parsed, "command", "cmd", "script", "input"); command != "" {
return strconv.Quote(command)
}
case strings.Contains(normalized, "read_file") || strings.Contains(normalized, "write_file") || strings.Contains(normalized, "delete_file") || strings.Contains(normalized, "stat_file"):
if path := firstStringField(parsed, "path", "file_path", "filename"); path != "" {
return "(" + path + ")"
}
case normalized == "get_pull_request":
owner := firstStringField(parsed, "owner")
repo := firstStringField(parsed, "repo", "repository")
switch {
case owner != "" && repo != "":
return "(" + owner + "/" + repo + ")"
case repo != "":
return "(" + repo + ")"
}
case strings.Contains(normalized, "workspace"):
if workspace := firstStringField(parsed, "workspace_name", "name", "workspace"); workspace != "" {
return "(" + workspace + ")"
}
}
return ""
}
func firstStringField(value any, keys ...string) string {
object, ok := value.(map[string]any)
if !ok {
return ""
}
for _, key := range keys {
fieldValue, ok := object[key]
if !ok {
continue
}
if text := firstShortStringValue(fieldValue); text != "" {
return text
}
}
return ""
}
func firstShortStringValue(value any) string {
switch typed := value.(type) {
case string:
trimmed := strings.Join(strings.Fields(strings.TrimSpace(typed)), " ")
if trimmed == "" {
return ""
}
return trimmed
case []any:
for _, item := range typed {
if text := firstShortStringValue(item); text != "" {
return text
}
}
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
if text := firstShortStringValue(typed[key]); text != "" {
return text
}
}
}
return ""
}
func toolDisplayLabel(toolName string, kind chatBlockKind, collapsedCount int) string {
label := humanizeToolName(toolName)
if collapsedCount <= 1 {
return label
}
switch kind {
case blockToolCall:
return label + "..."
case blockToolResult:
return fmt.Sprintf("%s (x%d)", label, collapsedCount)
default:
return label
}
}
func renderToolLine(styles tuiStyles, labelStyle lipgloss.Style, icon, label, summary string, width int) string {
label = sanitizeTerminalRenderableText(label)
summary = sanitizeTerminalRenderableText(summary)
header := toolBlockIndent + labelStyle.Render(icon) + " " + label
if summary == "" || width <= 0 {
return header
}
available := width - lipgloss.Width(header) - 1
preview := styles.truncate(summary, max(available, 0))
if preview == "" {
return header
}
return header + " " + styles.dimmedText.Render(preview)
}
func renderToolDetail(styles tuiStyles, label, value string, width int) string {
value = sanitizeTerminalRenderableText(value)
if strings.TrimSpace(value) == "" {
return ""
}
prefix := toolDetailIndent + label + ": "
wrapped := wrapPreservingNewlines(value, contentWidth(width, lipgloss.Width(prefix)))
lines := strings.Split(wrapped, "\n")
for i := range lines {
if i == 0 {
lines[i] = prefix + lines[i]
continue
}
lines[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + lines[i]
}
return styles.dimmedText.Render(strings.Join(lines, "\n"))
}
func renderExpandedToolBlock(styles tuiStyles, labelStyle lipgloss.Style, icon, toolName, args, result string, width int) string {
lines := []string{toolBlockIndent + labelStyle.Render(icon) + " " + humanizeToolName(toolName)}
if argsLine := renderToolDetail(styles, "args", args, width); argsLine != "" {
lines = append(lines, argsLine)
}
if resultLine := renderToolDetail(styles, "result", result, width); resultLine != "" {
lines = append(lines, resultLine)
}
return strings.Join(lines, "\n")
}
func toolResultIconAndStyle(styles tuiStyles, block chatBlock) (string, lipgloss.Style) {
if block.isError {
return "✗", styles.errorText
}
return "✓", styles.toolSuccess
}
func renderToolCallBlock(styles tuiStyles, block chatBlock, width int) string {
if block.toolName == contextCompactionToolName {
return renderCompaction(styles, width)
}
return renderToolLine(
styles,
styles.toolPending,
pendingToolIcon,
toolDisplayLabel(block.toolName, block.kind, block.collapsedCount),
summarizeToolContent(block.toolName, block.args),
width,
)
}
func renderToolResultBlock(styles tuiStyles, block chatBlock, width int) string {
if block.toolName == contextCompactionToolName {
return renderCompaction(styles, width)
}
icon, labelStyle := toolResultIconAndStyle(styles, block)
summary := summarizeToolContent(block.toolName, block.args)
if summary == "" && block.isError {
summary = summarizeToolContent("", block.result, "error", "message", "detail", "stderr")
}
if summary == "" {
summary = toolResultSummary(block.toolName, "", block.result)
}
return renderToolLine(
styles,
labelStyle,
icon,
toolDisplayLabel(block.toolName, block.kind, block.collapsedCount),
summary,
width,
)
}
func renderCompaction(styles tuiStyles, width int) string {
banner := styles.compaction.Render("🗜️ Context compacted")
if width <= 0 {
return banner
}
return lipgloss.PlaceHorizontal(width, lipgloss.Center, banner)
}
func contentWidth(width, inset int) int {
if width <= 0 {
return 80
}
return max(width-inset, 1)
}
func renderOverlayFrame(styles tuiStyles, width int, sections ...string) string {
sections = slices.DeleteFunc(sections, func(section string) bool { return section == "" })
return styles.overlayBorder.Width(contentWidth(width, 6)).Render(strings.Join(sections, "\n\n"))
}
func diffMetadataLines(diff codersdk.ChatDiffContents) []string {
var lines []string
if diff.Branch != nil && *diff.Branch != "" {
lines = append(lines, fmt.Sprintf("Branch: %s", *diff.Branch))
}
if diff.PullRequestURL != nil && *diff.PullRequestURL != "" {
lines = append(lines, fmt.Sprintf("PR: %s", *diff.PullRequestURL))
}
return lines
}
func parseChatGitChangesFromUnifiedDiff(diff codersdk.ChatDiffContents) []codersdk.ChatGitChange {
rawDiff := sanitizeTerminalRenderableText(diff.Diff)
if strings.TrimSpace(rawDiff) == "" {
return nil
}
var (
changes []codersdk.ChatGitChange
current *codersdk.ChatGitChange
currentAdditions int
currentDeletions int
inHunk bool
)
flush := func() {
if current == nil {
return
}
if current.FilePath == "" {
current = nil
currentAdditions = 0
currentDeletions = 0
return
}
if currentAdditions > 0 || currentDeletions > 0 {
stats := make([]string, 0, 2)
if currentAdditions > 0 {
stats = append(stats, fmt.Sprintf("+%d", currentAdditions))
}
if currentDeletions > 0 {
stats = append(stats, fmt.Sprintf("-%d", currentDeletions))
}
summary := strings.Join(stats, " ")
current.DiffSummary = &summary
}
changes = append(changes, *current)
current = nil
currentAdditions = 0
currentDeletions = 0
}
for line := range strings.SplitSeq(rawDiff, "\n") {
switch {
case strings.HasPrefix(line, "diff --git "):
flush()
inHunk = false
// parseUnifiedDiffHeaderPaths may return ("", "", false) when
// the unquoted header form is ambiguous, such as a rename with
// spaces in the paths. We still want to start a new entry so
// the follow-up rename from / rename to / --- / +++ lines can
// populate the correct paths. flush() drops entries that never
// received a FilePath.
oldPath, newPath, _ := parseUnifiedDiffHeaderPaths(line)
current = &codersdk.ChatGitChange{
ChatID: diff.ChatID,
FilePath: newPath,
ChangeType: "modified",
}
if oldPath != "" && newPath != "" && oldPath != newPath {
oldPathCopy := oldPath
current.OldPath = &oldPathCopy
current.ChangeType = "renamed"
}
case current == nil:
continue
case strings.HasPrefix(line, "@@"):
// Entering a hunk. Everything from here until the next
// "diff --git " header is diff content, including any
// added/removed lines that happen to start with "--- "
// or "+++ ". Those must no longer be treated as file
// headers.
inHunk = true
case !inHunk && strings.HasPrefix(line, "new file mode "):
current.ChangeType = "added"
case !inHunk && strings.HasPrefix(line, "deleted file mode "):
current.ChangeType = "deleted"
case !inHunk && strings.HasPrefix(line, "rename from "):
// rename from/rename to paths are repository-relative and
// never carry the a/ or b/ prefix, so we must not strip
// those segments: a real file at a/foo.txt would otherwise
// be truncated to foo.txt.
oldPath := decodeQuotedDiffLinePath(strings.TrimPrefix(line, "rename from "))
if oldPath != "" {
oldPathCopy := oldPath
current.OldPath = &oldPathCopy
}
current.ChangeType = "renamed"
case !inHunk && strings.HasPrefix(line, "rename to "):
newPath := decodeQuotedDiffLinePath(strings.TrimPrefix(line, "rename to "))
if newPath != "" {
current.FilePath = newPath
}
current.ChangeType = "renamed"
case !inHunk && strings.HasPrefix(line, "--- /dev/null"):
current.ChangeType = "added"
case !inHunk && strings.HasPrefix(line, "+++ /dev/null"):
current.ChangeType = "deleted"
case !inHunk && strings.HasPrefix(line, "--- "):
if current.ChangeType == "added" {
continue
}
if oldPath := trimUnifiedDiffPath(strings.TrimPrefix(line, "--- ")); oldPath != "" && oldPath != "/dev/null" {
oldPathCopy := oldPath
current.OldPath = &oldPathCopy
}
case !inHunk && strings.HasPrefix(line, "+++ "):
if current.ChangeType == "deleted" {
continue
}
if newPath := trimUnifiedDiffPath(strings.TrimPrefix(line, "+++ ")); newPath != "" && newPath != "/dev/null" {
current.FilePath = newPath
}
case inHunk && strings.HasPrefix(line, "+"):
currentAdditions++
case inHunk && strings.HasPrefix(line, "-"):
currentDeletions++
}
}
flush()
return changes
}
// parseUnifiedDiffHeaderPaths extracts the old and new paths from a
// `diff --git ...` header line. Git emits paths in one of two forms:
//
// 1. Quoted: `diff --git "a/<old>" "b/<new>"`. Used when paths contain
// control characters, backslashes, double quotes, or (with the default
// core.quotepath setting) bytes above 0x7f. The contents are C-quoted.
// 2. Unquoted: `diff --git a/<old> b/<new>`. Used for simple paths, which
// may still contain spaces. Because there is no delimiter between the
// two paths, this form is ambiguous when paths contain spaces: we rely
// on the git convention that non-rename diffs repeat the same path in
// both halves.
//
// For the unquoted form we first search for a split point at ` b/` where
// the left and right halves are equal after stripping the `a/` and `b/`
// prefixes (the non-rename case). If that fails but the line contains only
// a single space, we split there for simple renames with no embedded
// whitespace. Otherwise we return ok=false and let the caller rely on the
// subsequent `rename from`, `rename to`, `--- `, and `+++ ` lines.
func parseUnifiedDiffHeaderPaths(line string) (oldPath string, newPath string, ok bool) {
raw := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
if raw == "" {
return "", "", false
}
if strings.HasPrefix(raw, `"`) {
old, rest, ok := consumeQuotedDiffPath(raw)
if !ok {
return "", "", false
}
rest = strings.TrimLeft(rest, " ")
newp, _, ok := consumeQuotedDiffPath(rest)
if !ok {
return "", "", false
}
// The unquoted values already have their surrounding quotes removed,
// so we must not feed them to trimUnifiedDiffPath (which would strip
// any legitimate leading or trailing quote characters in the file
// name). Only strip the a/ or b/ prefix here.
return stripUnifiedDiffPrefix(old), stripUnifiedDiffPrefix(newp), true
}
if !strings.HasPrefix(raw, "a/") {
return "", "", false
}
for offset := 0; offset < len(raw); {
idx := strings.Index(raw[offset:], " b/")
if idx < 0 {
break
}
pos := offset + idx
left := trimUnifiedDiffPath(raw[:pos])
right := trimUnifiedDiffPath(raw[pos+1:])
if left == right {
return left, right, true
}
offset = pos + 1
}
// No equal split was found. If the line only contains a single space,
// the split is unambiguous and this is a simple rename whose paths
// happen to differ. Splitting the quoted-path form was handled above,
// so we know the raw form has no quoting to worry about here.
if strings.Count(raw, " ") == 1 {
idx := strings.Index(raw, " b/")
if idx > 0 {
return trimUnifiedDiffPath(raw[:idx]), trimUnifiedDiffPath(raw[idx+1:]), true
}
}
return "", "", false
}
// consumeQuotedDiffPath reads one C-quoted path from the start of s and
// returns the unquoted value along with the remainder of the string. The
// leading character of s must be `"`. git's C-quoting matches Go's quoted
// string syntax closely enough for strconv.Unquote to handle the common
// cases (octal byte escapes like `\303`, and the usual `\t`, `\n`, `\"`,
// `\\`).
func consumeQuotedDiffPath(s string) (path string, rest string, ok bool) {
if !strings.HasPrefix(s, `"`) {
return "", "", false
}
for i := 1; i < len(s); i++ {
switch s[i] {
case '\\':
// Skip the next byte so an escaped quote does not terminate
// the literal early. Bounds-check to avoid running off the
// end of a malformed input.
if i+1 >= len(s) {
return "", "", false
}
i++
case '"':
unq, err := strconv.Unquote(s[:i+1])
if err != nil {
return "", "", false
}
return unq, s[i+1:], true
}
}
return "", "", false
}
// trimUnifiedDiffPath decodes a path taken from a `--- ` or `+++ ` line
// of a unified diff. Those lines always prefix the path with `a/` or `b/`,
// so the prefix is stripped after any C-quote decoding.
func trimUnifiedDiffPath(path string) string {
return stripUnifiedDiffPrefix(decodeQuotedDiffLinePath(path))
}
// decodeQuotedDiffLinePath decodes a git-emitted path without stripping
// any `a/` or `b/` prefix. Git only adds those prefixes to `diff --git`,
// `--- `, and `+++ ` lines, so `rename from`, `rename to`, and similar
// lines must use this helper to avoid truncating a real leading `a/` or
// `b/` directory component.
func decodeQuotedDiffLinePath(path string) string {
path = strings.TrimSpace(path)
// Git quotes the whole path with double quotes and C-style escapes when
// it contains control characters, backslashes, double quotes, or (with
// the default core.quotepath setting) bytes above 0x7f. strconv.Unquote
// understands the same escape vocabulary for the common cases.
if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
if unq, err := strconv.Unquote(path); err == nil {
return unq
}
return strings.Trim(path, `"`)
}
return path
}
func stripUnifiedDiffPrefix(path string) string {
switch {
case strings.HasPrefix(path, "a/"), strings.HasPrefix(path, "b/"):
return path[2:]
default:
return path
}
}
// agentgitOversizePlaceholderPrefix matches the literal prefix that
// agent/agentgit substitutes for a repository's UnifiedDiff when the
// raw diff exceeds maxTotalDiffSize (3 MiB). See
// agent/agentgit/agentgit.go. Multi-repo aggregates assembled by
// buildLocalChatDiffContents can mix real `diff --git` chunks with
// this placeholder, in which case parseChatGitChangesFromUnifiedDiff
// returns a non-zero count for the real chunks while silently
// dropping the placeholder repo. Detecting the prefix separately
// lets renderChatDiffSummary flag the omission so the user is not
// misled into thinking the summary is exhaustive. Kept as a local
// prefix match because the coupling is narrow and the string is
// stable.
const agentgitOversizePlaceholderPrefix = "Total diff too large to show. Size:"
// hasOversizedRepoPlaceholder reports whether the combined unified
// diff contains at least one agentgit oversize-repo placeholder.
// Matching is scoped to lines that start with the placeholder prefix
// so a false positive from a diff body that legitimately contains the
// phrase (e.g. as a `+` added line inside a real patch) cannot
// trigger the omission notice. agentgit always writes the
// placeholder as the entire UnifiedDiff for a repo, and
// buildLocalChatDiffContents joins segments with "\n", so a real
// placeholder repo always appears on its own line after the join.
func hasOversizedRepoPlaceholder(diff string) bool {
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, agentgitOversizePlaceholderPrefix) {
return true
}
}
return false
}
func renderChatDiffSummary(diff codersdk.ChatDiffContents) string {
changes := parseChatGitChangesFromUnifiedDiff(diff)
if len(changes) == 0 {
// The diff text might be non-empty but not in `diff --git`
// format (for example `agent/agentgit` emits a "Total diff
// too large to show..." placeholder when the raw diff exceeds
// the read limit). Report that changes exist but could not
// be summarized so we do not mislead the user into thinking
// the workspace is clean.
if strings.TrimSpace(diff.Diff) != "" {
return "Changes present but could not be summarized."
}
return "No changes detected."
}
label := "files"
if len(changes) == 1 {
label = "file"
}
lines := []string{fmt.Sprintf("%d %s changed:", len(changes), label)}
for _, change := range changes {
path := sanitizeTerminalRenderableText(change.FilePath)
if change.ChangeType == "renamed" && change.OldPath != nil && *change.OldPath != "" {
path = fmt.Sprintf("%s → %s", sanitizeTerminalRenderableText(*change.OldPath), path)
}
line := fmt.Sprintf(" %-8s %s", change.ChangeType, path)
if change.DiffSummary != nil && strings.TrimSpace(*change.DiffSummary) != "" {
line = fmt.Sprintf("%s (%s)", line, sanitizeTerminalRenderableText(*change.DiffSummary))
}
lines = append(lines, line)
}
// A multi-repo aggregate can mix real diff chunks (counted
// above) with agentgit's oversize placeholder for repos whose
// raw diff exceeds maxTotalDiffSize. The placeholder does not
// contribute to the files-changed count because it is not in
// `diff --git` format, so without this notice the summary would
// silently underreport the changeset.
if hasOversizedRepoPlaceholder(diff.Diff) {
lines = append(lines, " (some repositories omitted: diff too large to summarize)")
}
return strings.Join(lines, "\n")
}
func renderStyledDiffBody(styles tuiStyles, diff string) string {
diff = sanitizeTerminalRenderableText(diff)
if strings.TrimSpace(diff) == "" {
return styles.dimmedText.Render("No diff contents.")
}
lines := strings.Split(diff, "\n")
inHunk := false
for i, line := range lines {
// Track whether we're inside a hunk body so styling can
// distinguish legitimate header `--- `/`+++ ` lines from
// additions/deletions whose content happens to start with
// those prefixes (for example a `+++ ` content line whose
// text begins with `++ `). Matches the parser's inHunk
// bookkeeping in parseChatGitChangesFromUnifiedDiff.
switch {
case strings.HasPrefix(line, "diff --git "):
inHunk = false
case strings.HasPrefix(line, "@@"):
inHunk = true
}
lines[i] = styleUnifiedDiffLine(styles, line, inHunk)
}
return strings.Join(lines, "\n")
}
func styleUnifiedDiffLine(styles tuiStyles, line string, inHunk bool) string {
switch {
case strings.HasPrefix(line, "diff --git "):
return styles.selectedItem.Render(line)
case strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "new file mode "),
strings.HasPrefix(line, "deleted file mode "),
strings.HasPrefix(line, "rename from "),
strings.HasPrefix(line, "rename to "),
strings.HasPrefix(line, "Binary files "):
return styles.subtitle.Render(line)
case !inHunk && (strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ")):
return styles.subtitle.Render(line)
case strings.HasPrefix(line, "@@"):
return styles.warningText.Render(line)
case strings.HasPrefix(line, "+"):
return styles.toolSuccess.Render(line)
case strings.HasPrefix(line, "-"):
return styles.errorText.Render(line)
default:
return line
}
}
// renderDiffDrawer builds the diff overlay contents. The caller is
// responsible for producing summary with renderChatDiffSummary and
// styledBody with renderStyledDiffBody so that every View() redraw
// does not walk the full (potentially 4 MiB) diff through
// parseChatGitChangesFromUnifiedDiff or re-style every line through
// lipgloss. chatViewModel caches both in diffSummary and
// diffStyledBody for this reason. If styledBody is empty the caller
// had no cache (for example tests that construct diffs directly), so
// fall back to computing it here instead of silently rendering an
// empty body.
func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, summary, styledBody string, width, height int) string {
innerWidth := contentWidth(width, 6)
headerBits := []string{styles.title.Render("Diff")}
if meta := diffMetadataLines(diff); len(meta) > 0 {
headerBits = append(headerBits, styles.subtitle.Render(strings.Join(meta, " • ")))
}
diffBody := styledBody
if diffBody == "" {
diffBody = renderStyledDiffBody(styles, diff.Diff)
}
help := styles.helpText.Render("Esc to close")
overhead := countRenderedLines(strings.Join(headerBits, "\n")) + countRenderedLines(summary) + countRenderedLines(help) + 4
availableBodyLines := max(height-overhead, 0)
if height <= 0 {
availableBodyLines = 12
}
wrappedDiff := wrapPreservingNewlines(diffBody, innerWidth)
if availableBodyLines == 0 {
wrappedDiff = ""
} else {
wrappedDiff = clampLines(wrappedDiff, availableBodyLines)
}
return renderOverlayFrame(styles, width, strings.Join(headerBits, "\n"), summary, wrappedDiff, help)
}
func renderModelPicker(styles tuiStyles, catalog codersdk.ChatModelsResponse, selected string, cursor int, width, height int) string {
innerWidth := contentWidth(width, 6)
lines := []string{styles.title.Render("Select Model")}
cursorLine := 0
hasModels := false
flatIndex := 0
for _, provider := range catalog.Providers {
if len(provider.Models) == 0 {
continue
}
lines = append(lines, styles.subtitle.Render(provider.Provider))
if !provider.Available {
reason := string(provider.UnavailableReason)
if reason == "" {
reason = "unavailable"
}
lines = append(lines, " "+styles.dimmedText.Render(reason))
lines = append(lines, "")
continue
}
for _, model := range provider.Models {
hasModels = true
name := model.DisplayName
if strings.TrimSpace(name) == "" {
name = model.Model
}
marker := " "
if flatIndex == cursor {
marker = "> "
}
rowStyle := styles.normalItem
if model.ID == selected {
rowStyle = styles.selectedItem
}
lines = append(lines, marker+rowStyle.Render(styles.truncate(name, max(innerWidth-2, 0))))
if flatIndex == cursor {
cursorLine = len(lines) - 1
}
flatIndex++
}
lines = append(lines, "")
}
if !hasModels {
lines = append(lines, styles.dimmedText.Render("No models available."))
lines = append(lines, "")
}
help := styles.helpText.Render("Esc to close, Enter to select")
contentLines := lines
maxContentLines := max(height-countRenderedLines(help)-4, 1)
if height <= 0 {
maxContentLines = len(contentLines)
}
windowStart := 0
if cursorLine >= maxContentLines {
windowStart = cursorLine - maxContentLines + 1
}
maxWindowStart := max(len(contentLines)-maxContentLines, 0)
windowStart = min(windowStart, maxWindowStart)
windowEnd := min(windowStart+maxContentLines, len(contentLines))
content := append([]string(nil), contentLines[windowStart:windowEnd]...)
content = append(content, help)
return renderOverlayFrame(styles, width, strings.Join(content, "\n"))
}
func renderAskUserQuestion(styles tuiStyles, state *askUserQuestionState, width, height int) string {
if state == nil || len(state.Questions) == 0 {
return ""
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return ""
}
innerWidth := contentWidth(width, 6)
question := state.Questions[state.CurrentIndex]
sections := []string{styles.title.Render(fmt.Sprintf("Plan Question %d/%d", state.CurrentIndex+1, len(state.Questions)))}
if question.Header != "" {
sections = append(sections, styles.subtitle.Render(sanitizeTerminalRenderableText(question.Header)))
}
sections = append(sections, wrapPreservingNewlines(sanitizeTerminalRenderableText(question.Question), innerWidth))
if state.Submitting {
sections = append(sections, styles.dimmedText.Render("Submitting answers..."))
return renderOverlayFrame(styles, width, sections...)
}
optionLines := make([]string, 0, len(question.Options)+3)
for i, option := range question.Options {
label := strings.TrimSpace(sanitizeTerminalRenderableText(option.Label))
if label == "" {
label = "(empty option)"
}
label = styles.truncate(label, max(innerWidth-2, 0))
row := " " + label
if i == state.OptionCursor {
row = styles.selectedItem.Render("> " + label)
}
optionLines = append(optionLines, row)
}
otherLabel := styles.truncate("Other (type custom answer)", max(innerWidth-2, 0))
otherRow := " " + otherLabel
if state.OptionCursor == len(question.Options) {
otherRow = styles.selectedItem.Render("> " + otherLabel)
}
optionLines = append(optionLines, otherRow)
if state.OtherMode {
optionLines = append(optionLines, "", state.OtherInput.View())
}
sections = append(sections, strings.Join(optionLines, "\n"))
if state.Error != nil {
sections = append(sections, styles.errorText.Render(wrapPreservingNewlines(
"Error: "+sanitizeTerminalRenderableText(state.Error.Error()),
innerWidth,
)))
}
longHelpParts := []string{"↑/↓ navigate", "enter select"}
shortHelpParts := []string{"↑↓", "↵"}
compactHelpParts := []string{"↑↓", "↵"}
if state.CurrentIndex > 0 {
longHelpParts = append(longHelpParts, "←/h back")
shortHelpParts = append(shortHelpParts, "←/h")
compactHelpParts = append(compactHelpParts, "←")
}
if state.OtherMode {
longHelpParts = append(longHelpParts, "esc cancel input")
shortHelpParts = append(shortHelpParts, "esc input")
compactHelpParts = append(compactHelpParts, "esc")
}
sections = append(sections, styles.helpText.Render(fitHelpText(
innerWidth,
strings.Join(longHelpParts, " | "),
strings.Join(shortHelpParts, " │ "),
strings.Join(compactHelpParts, " "),
)))
_ = height
return renderOverlayFrame(styles, width, sections...)
}
//nolint:revive // Signature is dictated by the chat TUI view code.
func renderChatBlocks(styles tuiStyles, blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool, composerFocused bool, width int, renderers ...*glamour.TermRenderer) string {
if len(blocks) == 0 {
return ""
}
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
activeSelection := -1
if !composerFocused {
activeSelection = selectedBlock
}
visibleIndices := collapseConsecutiveSameNameBlocks(blocks, activeSelection, expandedBlocks)
rendered := make([]string, 0, len(visibleIndices))
for _, index := range visibleIndices {
blockView := blocks[index].cachedRender
if blockView == "" ||
blocks[index].cachedWidth != width ||
blocks[index].cachedExpanded != expandedBlocks[index] ||
blocks[index].cachedCollapsedCount != blocks[index].collapsedCount {
blockView = renderBlock(styles, blocks[index], expandedBlocks[index], width, renderer)
blocks[index].cachedRender = blockView
blocks[index].cachedWidth = width
blocks[index].cachedExpanded = expandedBlocks[index]
blocks[index].cachedCollapsedCount = blocks[index].collapsedCount
}
if index == activeSelection {
blockView = styles.selectedBlock.Render(blockView)
}
rendered = append(rendered, blockView)
}
return strings.Join(rendered, "\n")
}
//nolint:revive // Signature is dictated by the chat TUI view code.
func renderStatusBar(styles tuiStyles, chat *codersdk.Chat, status codersdk.ChatStatus, usage *codersdk.ChatMessageUsage, queueCount int, interrupting, reconnecting bool, width int) string {
_ = chat
parts := []string{styles.statusColor(status).Render(string(status))}
if usage != nil && usage.TotalTokens != nil && usage.ContextLimit != nil {
total := *usage.TotalTokens
limit := *usage.ContextLimit
if limit > 0 {
tokenText := fmt.Sprintf("tokens: %d/%d", total, limit)
pct := float64(total) / float64(limit) * 100
switch {
case pct > 95:
tokenText = styles.criticalText.Render(tokenText)
case pct > 80:
tokenText = styles.warningText.Render(tokenText)
}
parts = append(parts, tokenText)
}
}
if queueCount > 0 {
parts = append(parts, fmt.Sprintf("queued: %d", queueCount))
}
if interrupting {
parts = append(parts, styles.warningText.Render("interrupting…"))
}
if reconnecting {
parts = append(parts, styles.warningText.Render("reconnecting…"))
}
line := strings.Join(parts, styles.separator.Render(" │ "))
bar := styles.statusBar
if width > 0 {
bar = bar.MaxWidth(width)
}
return bar.Render(line)
}
func collapseConsecutiveSameNameBlocks(blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool) []int {
if len(blocks) == 0 {
return nil
}
for i := range blocks {
blocks[i].collapsedCount = 0
}
visibleIndices := make([]int, 0, len(blocks))
for i := 0; i < len(blocks); {
runEnd := i + 1
for runEnd < len(blocks) && canCollapseToolBlocks(blocks[i], blocks[runEnd]) {
runEnd++
}
if runEnd-i < 2 || hasExpandedToolBlock(expandedBlocks, i, runEnd) {
for j := i; j < runEnd; j++ {
visibleIndices = append(visibleIndices, j)
}
i = runEnd
continue
}
representative := i
if selectedBlock >= i && selectedBlock < runEnd {
representative = selectedBlock
}
blocks[representative].collapsedCount = runEnd - i
visibleIndices = append(visibleIndices, representative)
i = runEnd
}
return visibleIndices
}
func canCollapseToolBlocks(a, b chatBlock) bool {
if a.kind != b.kind {
return false
}
if a.kind != blockToolCall && a.kind != blockToolResult {
return false
}
if a.toolName != b.toolName {
return false
}
if a.kind == blockToolResult && a.isError != b.isError {
return false
}
if a.args != b.args || a.result != b.result {
return false
}
return true
}
func hasExpandedToolBlock(expandedBlocks map[int]bool, start, end int) bool {
for i := start; i < end; i++ {
if expandedBlocks[i] {