on main help sign in
Merge remote-tracking branch 'origin/main' into ref
d475c22Carlos Alexandro Becker
cspell.json+1−1
@@ -1 +1 @@
1−{"version":"0.2","language":"en","flagWords":[],"words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos","fsext"]}
1+{"flagWords":[],"words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos","fsext","GROQ","alecthomas","Preproc","Emph","charmtone","Charple","Guac","diffview","Strikethrough","Unticked","uniseg","rivo"],"version":"0.2","language":"en"}
internal/app/app.go+1
@@ -163,4 +163,5 @@ func (app *App) Shutdown() {
163 }
163 }
164 cancel()
164 cancel()
165 }
165 }
166+ app.CoderAgent.CancelAll()
166}
167}
internal/exp/diffview/Taskfile.yaml+20
@@ -100,3 +100,23 @@ tasks:
100 - for: sources
100 - for: sources
101 cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
101 cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
102 silent: true
102 silent: true
103+
104+ test:print:yoffset:unified:infinite:
105+ desc: Print golden files for debugging
106+ method: none
107+ sources:
108+ - ./testdata/TestDiffViewYOffsetInfinite/Unified/*.golden
109+ cmds:
110+ - for: sources
111+ cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
112+ silent: true
113+
114+ test:print:yoffset:split:infinite:
115+ desc: Print golden files for debugging
116+ method: none
117+ sources:
118+ - ./testdata/TestDiffViewYOffsetInfinite/Split/*.golden
119+ cmds:
120+ - for: sources
121+ cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
122+ silent: true
internal/exp/diffview/diffview.go+235−177
@@ -33,18 +33,19 @@ const (
33
33
34// DiffView represents a view for displaying differences between two files.
34// DiffView represents a view for displaying differences between two files.
35type DiffView struct {
35type DiffView struct {
36− layout layout
36+ layout layout
37− before file
37+ before file
38− after file
38+ after file
39− contextLines int
39+ contextLines int
40− lineNumbers bool
40+ lineNumbers bool
41− height int
41+ height int
42− width int
42+ width int
43− xOffset int
43+ xOffset int
44− yOffset int
44+ yOffset int
45+ infiniteYScroll bool
45− style Style
46+ style Style
46− tabWidth int
47+ tabWidth int
47− chromaStyle *chroma.Style
48+ chromaStyle *chroma.Style
48
49
49 isComputed bool
50 isComputed bool
50 err error
51 err error
@@ -53,6 +54,7 @@ type DiffView struct {
53
54
54 splitHunks []splitHunk
55 splitHunks []splitHunk
55
56
57+ totalLines int
56 codeWidth int
58 codeWidth int
57 fullCodeWidth int // with leading symbols
59 fullCodeWidth int // with leading symbols
58 extraColOnAfter bool // add extra column on after panel
60 extraColOnAfter bool // add extra column on after panel
@@ -138,6 +140,12 @@ func (dv *DiffView) YOffset(yOffset int) *DiffView {
138 return dv
140 return dv
139}
141}
140
142
143+// InfiniteYScroll allows the YOffset to scroll beyond the last line.
144+func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
145+ dv.infiniteYScroll = infiniteYScroll
146+ return dv
147+}
148+
141// TabWidth sets the tab width. Only relevant for code that contains tabs, like
149// TabWidth sets the tab width. Only relevant for code that contains tabs, like
142// Go code.
150// Go code.
143func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
151func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
@@ -161,6 +169,8 @@ func (dv *DiffView) String() string {
161 dv.convertDiffToSplit()
169 dv.convertDiffToSplit()
162 dv.adjustStyles()
170 dv.adjustStyles()
163 dv.detectNumDigits()
171 dv.detectNumDigits()
172+ dv.detectTotalLines()
173+ dv.preventInfiniteYScroll()
164
174
165 if dv.width <= 0 {
175 if dv.width <= 0 {
166 dv.detectCodeWidth()
176 dv.detectCodeWidth()
@@ -251,6 +261,37 @@ func (dv *DiffView) detectNumDigits() {
251 }
261 }
252}
262}
253
263
264+func (dv *DiffView) detectTotalLines() {
265+ dv.totalLines = 0
266+
267+ switch dv.layout {
268+ case layoutUnified:
269+ for _, h := range dv.unified.Hunks {
270+ dv.totalLines += 1 + len(h.Lines)
271+ }
272+ case layoutSplit:
273+ for _, h := range dv.splitHunks {
274+ dv.totalLines += 1 + len(h.lines)
275+ }
276+ }
277+}
278+
279+func (dv *DiffView) preventInfiniteYScroll() {
280+ if dv.infiniteYScroll {
281+ return
282+ }
283+
284+ // clamp yOffset to prevent scrolling beyond the last line
285+ if dv.height > 0 {
286+ maxYOffset := max(0, dv.totalLines-dv.height)
287+ dv.yOffset = min(dv.yOffset, maxYOffset)
288+ } else {
289+ // if no height limit, ensure yOffset doesn't exceed total lines
290+ dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
291+ }
292+ dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
293+}
294+
254// detectCodeWidth calculates the maximum width of code lines in the diff view.
295// detectCodeWidth calculates the maximum width of code lines in the diff view.
255func (dv *DiffView) detectCodeWidth() {
296func (dv *DiffView) detectCodeWidth() {
256 switch dv.layout {
297 switch dv.layout {
@@ -321,23 +362,29 @@ func (dv *DiffView) renderUnified() string {
321
362
322 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
363 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
323 printedLines := -dv.yOffset
364 printedLines := -dv.yOffset
324−
325− write := func(s string) {
326− if printedLines >= 0 {
365+ shouldWrite := func() bool { return printedLines >= 0 }
327− b.WriteString(s)
366+
328− }
367+ getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
368+ content = strings.TrimSuffix(in, "\n")
369+ content = dv.hightlightCode(content, ls.Code.GetBackground())
370+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
371+ content = ansi.Truncate(content, dv.codeWidth, "…")
372+ leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
373+ return
329 }
374 }
330
375
331outer:
376outer:
332 for i, h := range dv.unified.Hunks {
377 for i, h := range dv.unified.Hunks {
378+ if shouldWrite() {
333− ls := dv.style.DividerLine
379+ ls := dv.style.DividerLine
334− if dv.lineNumbers {
380+ if dv.lineNumbers {
335− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
381+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
336− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
382+ b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
383+ }
384+ content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
385+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
386+ b.WriteString("\n")
337 }
387 }
338− content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
339− write(ls.Code.Width(dv.fullCodeWidth).Render(content))
340− write("\n")
341 printedLines++
388 printedLines++
342
389
343 beforeLine := h.FromLine
390 beforeLine := h.FromLine
@@ -349,80 +396,82 @@ outer:
349 isLastHunk := i+1 == len(dv.unified.Hunks)
396 isLastHunk := i+1 == len(dv.unified.Hunks)
350 isLastLine := j+1 == len(h.Lines)
397 isLastLine := j+1 == len(h.Lines)
351 if hasReachedHeight && (!isLastHunk || !isLastLine) {
398 if hasReachedHeight && (!isLastHunk || !isLastLine) {
399+ if shouldWrite() {
352− ls := dv.lineStyleForType(l.Kind)
400+ ls := dv.lineStyleForType(l.Kind)
353− if dv.lineNumbers {
401+ if dv.lineNumbers {
354− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
402+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
355− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
403+ b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
404+ }
405+ b.WriteString(fullContentStyle.Render(
406+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
407+ ))
408+ b.WriteRune('\n')
356 }
409 }
357− write(fullContentStyle.Render(
358− ls.Code.Width(dv.fullCodeWidth).Render(" …"),
359− ))
360− write("\n")
361 break outer
410 break outer
362 }
411 }
363
412
364− getContent := func(ls LineStyle) string {
365− content := strings.TrimSuffix(l.Content, "\n")
366− content = dv.hightlightCode(content, ls.Code.GetBackground())
367− content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
368− content = ansi.Truncate(content, dv.codeWidth, "…")
369− return content
370− }
371−
372− leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
373−
374 switch l.Kind {
413 switch l.Kind {
375 case udiff.Equal:
414 case udiff.Equal:
415+ if shouldWrite() {
376− ls := dv.style.EqualLine
416+ ls := dv.style.EqualLine
377− content := getContent(ls)
417+ content, leadingEllipsis := getContent(l.Content, ls)
378− if dv.lineNumbers {
418+ if dv.lineNumbers {
379− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
419+ b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
380− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
420+ b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
421+ }
422+ b.WriteString(fullContentStyle.Render(
423+ ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
424+ ))
381 }
425 }
382− write(fullContentStyle.Render(
383− ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
384− ))
385 beforeLine++
426 beforeLine++
386 afterLine++
427 afterLine++
387 case udiff.Insert:
428 case udiff.Insert:
429+ if shouldWrite() {
388− ls := dv.style.InsertLine
430+ ls := dv.style.InsertLine
389− content := getContent(ls)
431+ content, leadingEllipsis := getContent(l.Content, ls)
390− if dv.lineNumbers {
432+ if dv.lineNumbers {
391− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
433+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
392− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
434+ b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
435+ }
436+ b.WriteString(fullContentStyle.Render(
437+ ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
438+ ls.Code.Width(dv.codeWidth).Render(content),
439+ ))
393 }
440 }
394− write(fullContentStyle.Render(
395− ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
396− ls.Code.Width(dv.codeWidth).Render(content),
397− ))
398 afterLine++
441 afterLine++
399 case udiff.Delete:
442 case udiff.Delete:
443+ if shouldWrite() {
400− ls := dv.style.DeleteLine
444+ ls := dv.style.DeleteLine
401− content := getContent(ls)
445+ content, leadingEllipsis := getContent(l.Content, ls)
402− if dv.lineNumbers {
446+ if dv.lineNumbers {
403− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
447+ b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
404− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
448+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
449+ }
450+ b.WriteString(fullContentStyle.Render(
451+ ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
452+ ls.Code.Width(dv.codeWidth).Render(content),
453+ ))
405 }
454 }
406− write(fullContentStyle.Render(
407− ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
408− ls.Code.Width(dv.codeWidth).Render(content),
409− ))
410 beforeLine++
455 beforeLine++
411 }
456 }
412− write("\n")
457+ if shouldWrite() {
458+ b.WriteRune('\n')
459+ }
413
460
414 printedLines++
461 printedLines++
415 }
462 }
416 }
463 }
417
464
418 for printedLines < dv.height {
465 for printedLines < dv.height {
466+ if shouldWrite() {
419− ls := dv.style.MissingLine
467+ ls := dv.style.MissingLine
420− if dv.lineNumbers {
468+ if dv.lineNumbers {
421− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
469+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
422− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
470+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
471+ }
472+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
473+ b.WriteRune('\n')
423 }
474 }
424− write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
425− write("\n")
426 printedLines++
475 printedLines++
427 }
476 }
428
477
@@ -436,26 +485,32 @@ func (dv *DiffView) renderSplit() string {
436 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
485 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
437 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
486 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
438 printedLines := -dv.yOffset
487 printedLines := -dv.yOffset
439−
440− write := func(s string) {
441− if printedLines >= 0 {
488+ shouldWrite := func() bool { return printedLines >= 0 }
442− b.WriteString(s)
489+
443− }
490+ getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
491+ content = strings.TrimSuffix(in, "\n")
492+ content = dv.hightlightCode(content, ls.Code.GetBackground())
493+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
494+ content = ansi.Truncate(content, dv.codeWidth, "…")
495+ leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
496+ return
444 }
497 }
445
498
446outer:
499outer:
447 for i, h := range dv.splitHunks {
500 for i, h := range dv.splitHunks {
501+ if shouldWrite() {
448− ls := dv.style.DividerLine
502+ ls := dv.style.DividerLine
449− if dv.lineNumbers {
503+ if dv.lineNumbers {
450− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
504+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
451− }
505+ }
452− content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
506+ content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
453− write(ls.Code.Width(dv.fullCodeWidth).Render(content))
507+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
454− if dv.lineNumbers {
508+ if dv.lineNumbers {
455− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
509+ b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
510+ }
511+ b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
512+ b.WriteRune('\n')
456 }
513 }
457− write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
458− write("\n")
459 printedLines++
514 printedLines++
460
515
461 beforeLine := h.fromLine
516 beforeLine := h.fromLine
@@ -467,126 +522,129 @@ outer:
467 isLastHunk := i+1 == len(dv.unified.Hunks)
522 isLastHunk := i+1 == len(dv.unified.Hunks)
468 isLastLine := j+1 == len(h.lines)
523 isLastLine := j+1 == len(h.lines)
469 if hasReachedHeight && (!isLastHunk || !isLastLine) {
524 if hasReachedHeight && (!isLastHunk || !isLastLine) {
525+ if shouldWrite() {
470− ls := dv.style.MissingLine
526+ ls := dv.style.MissingLine
471− if l.before != nil {
527+ if l.before != nil {
472− ls = dv.lineStyleForType(l.before.Kind)
528+ ls = dv.lineStyleForType(l.before.Kind)
473− }
529+ }
474− if dv.lineNumbers {
530+ if dv.lineNumbers {
475− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
531+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
476− }
532+ }
477− write(beforeFullContentStyle.Render(
533+ b.WriteString(beforeFullContentStyle.Render(
478− ls.Code.Width(dv.fullCodeWidth).Render(" …"),
534+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
479− ))
535+ ))
480− ls = dv.style.MissingLine
536+ ls = dv.style.MissingLine
481− if l.after != nil {
537+ if l.after != nil {
482− ls = dv.lineStyleForType(l.after.Kind)
538+ ls = dv.lineStyleForType(l.after.Kind)
539+ }
540+ if dv.lineNumbers {
541+ b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
542+ }
543+ b.WriteString(afterFullContentStyle.Render(
544+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
545+ ))
546+ b.WriteRune('\n')
483 }
547 }
484− if dv.lineNumbers {
485− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
486− }
487− write(afterFullContentStyle.Render(
488− ls.Code.Width(dv.fullCodeWidth).Render(" …"),
489− ))
490− write("\n")
491 break outer
548 break outer
492 }
549 }
493
550
494− getContent := func(content string, ls LineStyle) string {
495− content = strings.TrimSuffix(content, "\n")
496− content = dv.hightlightCode(content, ls.Code.GetBackground())
497− content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
498− content = ansi.Truncate(content, dv.codeWidth, "…")
499− return content
500− }
501− getLeadingEllipsis := func(content string) bool {
502− return dv.xOffset > 0 && strings.TrimSpace(content) != ""
503− }
504−
505 switch {
551 switch {
506 case l.before == nil:
552 case l.before == nil:
553+ if shouldWrite() {
507− ls := dv.style.MissingLine
554+ ls := dv.style.MissingLine
508− if dv.lineNumbers {
555+ if dv.lineNumbers {
509− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
556+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
557+ }
558+ b.WriteString(beforeFullContentStyle.Render(
559+ ls.Code.Width(dv.fullCodeWidth).Render(" "),
560+ ))
510 }
561 }
511− write(beforeFullContentStyle.Render(
512− ls.Code.Width(dv.fullCodeWidth).Render(" "),
513− ))
514 case l.before.Kind == udiff.Equal:
562 case l.before.Kind == udiff.Equal:
563+ if shouldWrite() {
515− ls := dv.style.EqualLine
564+ ls := dv.style.EqualLine
516− content := getContent(l.before.Content, ls)
565+ content, leadingEllipsis := getContent(l.before.Content, ls)
517− leadingEllipsis := getLeadingEllipsis(content)
518− if dv.lineNumbers {
566+ if dv.lineNumbers {
519− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
567+ b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
568+ }
569+ b.WriteString(beforeFullContentStyle.Render(
570+ ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
571+ ))
520 }
572 }
521− write(beforeFullContentStyle.Render(
522− ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
523− ))
524 beforeLine++
573 beforeLine++
525 case l.before.Kind == udiff.Delete:
574 case l.before.Kind == udiff.Delete:
575+ if shouldWrite() {
526− ls := dv.style.DeleteLine
576+ ls := dv.style.DeleteLine
527− content := getContent(l.before.Content, ls)
577+ content, leadingEllipsis := getContent(l.before.Content, ls)
528− leadingEllipsis := getLeadingEllipsis(content)
529− if dv.lineNumbers {
578+ if dv.lineNumbers {
530− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
579+ b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
580+ }
581+ b.WriteString(beforeFullContentStyle.Render(
582+ ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
583+ ls.Code.Width(dv.codeWidth).Render(content),
584+ ))
531 }
585 }
532− write(beforeFullContentStyle.Render(
533− ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
534− ls.Code.Width(dv.codeWidth).Render(content),
535− ))
536 beforeLine++
586 beforeLine++
537 }
587 }
538
588
539 switch {
589 switch {
540 case l.after == nil:
590 case l.after == nil:
591+ if shouldWrite() {
541− ls := dv.style.MissingLine
592+ ls := dv.style.MissingLine
542− if dv.lineNumbers {
593+ if dv.lineNumbers {
543− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
594+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
595+ }
596+ b.WriteString(afterFullContentStyle.Render(
597+ ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
598+ ))
544 }
599 }
545− write(afterFullContentStyle.Render(
546− ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
547− ))
548 case l.after.Kind == udiff.Equal:
600 case l.after.Kind == udiff.Equal:
601+ if shouldWrite() {
549− ls := dv.style.EqualLine
602+ ls := dv.style.EqualLine
550− content := getContent(l.after.Content, ls)
603+ content, leadingEllipsis := getContent(l.after.Content, ls)
551− leadingEllipsis := getLeadingEllipsis(content)
552− if dv.lineNumbers {
604+ if dv.lineNumbers {
553− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
605+ b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
606+ }
607+ b.WriteString(afterFullContentStyle.Render(
608+ ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
609+ ))
554 }
610 }
555− write(afterFullContentStyle.Render(
556− ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
557− ))
558 afterLine++
611 afterLine++
559 case l.after.Kind == udiff.Insert:
612 case l.after.Kind == udiff.Insert:
613+ if shouldWrite() {
560− ls := dv.style.InsertLine
614+ ls := dv.style.InsertLine
561− content := getContent(l.after.Content, ls)
615+ content, leadingEllipsis := getContent(l.after.Content, ls)
562− leadingEllipsis := getLeadingEllipsis(content)
563− if dv.lineNumbers {
616+ if dv.lineNumbers {
564− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
617+ b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
618+ }
619+ b.WriteString(afterFullContentStyle.Render(
620+ ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
621+ ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
622+ ))
565 }
623 }
566− write(afterFullContentStyle.Render(
567− ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
568− ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
569− ))
570 afterLine++
624 afterLine++
571 }
625 }
572
626
573− write("\n")
627+ if shouldWrite() {
628+ b.WriteRune('\n')
629+ }
574
630
575 printedLines++
631 printedLines++
576 }
632 }
577 }
633 }
578
634
579 for printedLines < dv.height {
635 for printedLines < dv.height {
636+ if shouldWrite() {
580− ls := dv.style.MissingLine
637+ ls := dv.style.MissingLine
581− if dv.lineNumbers {
638+ if dv.lineNumbers {
582− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
639+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
583− }
640+ }
584− write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
641+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
585− if dv.lineNumbers {
642+ if dv.lineNumbers {
586− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
643+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
644+ }
645+ b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
646+ b.WriteRune('\n')
587 }
647 }
588− write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
589− write("\n")
590 printedLines++
648 printedLines++
591 }
649 }
592
650
internal/exp/diffview/diffview_test.go+27
@@ -283,6 +283,33 @@ func TestDiffViewYOffset(t *testing.T) {
283 }
283 }
284}
284}
285
285
286+func TestDiffViewYOffsetInfinite(t *testing.T) {
287+ for layoutName, layoutFunc := range LayoutFuncs {
288+ t.Run(layoutName, func(t *testing.T) {
289+ for yOffset := range 17 {
290+ t.Run(fmt.Sprintf("YOffsetOf%02d", yOffset), func(t *testing.T) {
291+ t.Parallel()
292+
293+ dv := diffview.New().
294+ Before("main.go", TestMultipleHunksBefore).
295+ After("main.go", TestMultipleHunksAfter).
296+ Style(diffview.DefaultLightStyle()).
297+ ChromaStyle(styles.Get("catppuccin-latte")).
298+ Height(5).
299+ YOffset(yOffset).
300+ InfiniteYScroll(true)
301+ dv = layoutFunc(dv)
302+
303+ output := dv.String()
304+ golden.RequireEqual(t, []byte(output))
305+
306+ assertHeight(t, 5, output)
307+ })
308+ }
309+ })
310+ }
311+}
312+
286func assertLineWidth(t *testing.T, expected int, output string) {
313func assertLineWidth(t *testing.T, expected int, output string) {
287 var lineWidth int
314 var lineWidth int
288 for line := range strings.SplitSeq(output, "\n") {
315 for line := range strings.SplitSeq(output, "\n") {
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …unc main() { 
4  7   7  …unc main() { 
5  8    -… fmt.Println("Hello, world!") 
5  8    -… fmt.Println("Hello, world!") 
6     8 +… content := "Hello, world!" 
6     8 +… content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …nc main() { 
4  7   7  …nc main() { 
5  8    -… fmt.Println("Hello, world!") 
5  8    -… fmt.Println("Hello, world!") 
6     8 +… content := "Hello, world!" 
6     8 +… content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …c main() { 
4  7   7  …c main() { 
5  8    -… fmt.Println("Hello, world!") 
5  8    -… fmt.Println("Hello, world!") 
6     8 +… content := "Hello, world!" 
6     8 +… content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … main() { 
4  7   7  … main() { 
5  8    -…fmt.Println("Hello, world!") 
5  8    -…fmt.Println("Hello, world!") 
6     8 +…content := "Hello, world!" 
6     8 +…content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …main() { 
4  7   7  …main() { 
5  8    -…mt.Println("Hello, world!") 
5  8    -…mt.Println("Hello, world!") 
6     8 +…ontent := "Hello, world!" 
6     8 +…ontent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …ain() { 
4  7   7  …ain() { 
5  8    -…t.Println("Hello, world!") 
5  8    -…t.Println("Hello, world!") 
6     8 +…ntent := "Hello, world!" 
6     8 +…ntent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …in() { 
4  7   7  …in() { 
5  8    -….Println("Hello, world!") 
5  8    -….Println("Hello, world!") 
6     8 +…tent := "Hello, world!" 
6     8 +…tent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …n() { 
4  7   7  …n() { 
5  8    -…Println("Hello, world!") 
5  8    -…Println("Hello, world!") 
6     8 +…ent := "Hello, world!" 
6     8 +…ent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …() { 
4  7   7  …() { 
5  8    -…rintln("Hello, world!") 
5  8    -…rintln("Hello, world!") 
6     8 +…nt := "Hello, world!" 
6     8 +…nt := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …) { 
4  7   7  …) { 
5  8    -…intln("Hello, world!") 
5  8    -…intln("Hello, world!") 
6     8 +…t := "Hello, world!" 
6     8 +…t := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … { 
4  7   7  … { 
5  8    -…ntln("Hello, world!") 
5  8    -…ntln("Hello, world!") 
6     8 +… := "Hello, world!" 
6     8 +… := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  …{ 
4  7   7  …{ 
5  8    -…tln("Hello, world!") 
5  8    -…tln("Hello, world!") 
6     8 +…:= "Hello, world!" 
6     8 +…:= "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…ln("Hello, world!") 
5  8    -…ln("Hello, world!") 
6     8 +…= "Hello, world!" 
6     8 +…= "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…n("Hello, world!") 
5  8    -…n("Hello, world!") 
6     8 +… "Hello, world!" 
6     8 +… "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…("Hello, world!") 
5  8    -…("Hello, world!") 
6     8 +…"Hello, world!" 
6     8 +…"Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…"Hello, world!") 
5  8    -…"Hello, world!") 
6     8 +…Hello, world!" 
6     8 +…Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…Hello, world!") 
5  8    -…Hello, world!") 
6     8 +…ello, world!" 
6     8 +…ello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…ello, world!") 
5  8    -…ello, world!") 
6     8 +…llo, world!" 
6     8 +…llo, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…llo, world!") 
5  8    -…llo, world!") 
6     8 +…lo, world!" 
6     8 +…lo, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden+1−1
@@ -1,6 +1,6 @@
1  …   …  @@ -5,5 +5,6 @@  
1  …   …  @@ -5,5 +5,6 @@  
2  5   5  … 
2  5   5  … 
3−  6   6  … 
3+  6   6   
4  7   7  … 
4  7   7  … 
5  8    -…lo, world!") 
5  8    -…lo, world!") 
6     8 +…o, world!" 
6     8 +…o, world!" 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden+2−2
@@ -1,5 +1,5 @@
1+ 10    11   
1 11  func getContent() string {  12  func getContent() string { 
2 11  func getContent() string {  12  func getContent() string { 
2 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3      14 +  return content 
4      14 +  return content 
4− 13  }  15  } 
5+ 13  }  15  } 
5−          
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden+3−3
@@ -1,5 +1,5 @@
1+ 10    11   
2+ 11  func getContent() string {  12  func getContent() string { 
1 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
2      14 +  return content 
4      14 +  return content 
3− 13  }  15  } 
5+ 13  }  15  } 
4−          
5−          
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden+4−4
@@ -1,5 +1,5 @@
1+ 10    11   
2+ 11  func getContent() string {  12  func getContent() string { 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
1      14 +  return content 
4      14 +  return content 
2− 13  }  15  } 
5+ 13  }  15  } 
3−          
4−          
5−          
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden+5−5
@@ -1,5 +1,5 @@
1+ 10    11   
2+ 11  func getContent() string {  12  func getContent() string { 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4+      14 +  return content 
1− 13  }  15  } 
5+ 13  }  15  } 
2−          
3−          
4−          
5−          
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden+5−5
@@ -1,5 +1,5 @@
1−          
1+ 10    11   
2−          
2+ 11  func getContent() string {  12  func getContent() string { 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3−          
4+      14 +  return content 
4−          
5+ 13  }  15  } 
5−          
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden+5−5
@@ -1,5 +1,5 @@
1−          
1+ 10    11   
2−          
2+ 11  func getContent() string {  12  func getContent() string { 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3−          
4+      14 +  return content 
4−          
5+ 13  }  15  } 
5−          
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden+2−2
@@ -1,5 +1,5 @@
1+ 11  12  func getContent() string { 
1 12    -  return "Hello, world!" 
2 12    -  return "Hello, world!" 
2    13 +  content := strings.ToUpper("Hello, World!") 
3    13 +  content := strings.ToUpper("Hello, World!") 
3    14 +  return content 
4    14 +  return content 
4− 13  15  } 
5+ 13  15  } 
5−        
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden+3−3
@@ -1,5 +1,5 @@
1+ 11  12  func getContent() string { 
2+ 12    -  return "Hello, world!" 
1    13 +  content := strings.ToUpper("Hello, World!") 
3    13 +  content := strings.ToUpper("Hello, World!") 
2    14 +  return content 
4    14 +  return content 
3− 13  15  } 
5+ 13  15  } 
4−        
5−        
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden+4−4
@@ -1,5 +1,5 @@
1+ 11  12  func getContent() string { 
2+ 12    -  return "Hello, world!" 
3+    13 +  content := strings.ToUpper("Hello, World!") 
1    14 +  return content 
4    14 +  return content 
2− 13  15  } 
5+ 13  15  } 
3−        
4−        
5−        
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden+5−5
@@ -1,5 +1,5 @@
1+ 11  12  func getContent() string { 
2+ 12    -  return "Hello, world!" 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4+    14 +  return content 
1− 13  15  } 
5+ 13  15  } 
2−        
3−        
4−        
5−        
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden+5−5
@@ -1,5 +1,5 @@
1−        
1+ 11  12  func getContent() string { 
2−        
2+ 12    -  return "Hello, world!" 
3−        
3+    13 +  content := strings.ToUpper("Hello, World!") 
4−        
4+    14 +  return content 
5−        
5+ 13  15  } 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.goldennew file+5
@@ -0,0 +1,5 @@
1+  …  @@ -2,6 +2,7 @@    …   
2+  2     2   
3+  3  import (   3  import ( 
4+  4   "fmt"   4   "fmt" 
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.goldennew file+5
@@ -0,0 +1,5 @@
1+  2     2   
2+  3  import (   3  import ( 
3+  4   "fmt"   4   "fmt" 
4+       5 +  "strings" 
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.goldennew file+5
@@ -0,0 +1,5 @@
1+  3  import (   3  import ( 
2+  4   "fmt"   4   "fmt" 
3+       5 +  "strings" 
4+  5  )   6  ) 
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.goldennew file+5
@@ -0,0 +1,5 @@
1+  4   "fmt"   4   "fmt" 
2+       5 +  "strings" 
3+  5  )   6  ) 
4+  6     7   
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.goldennew file+5
@@ -0,0 +1,5 @@
1+       5 +  "strings" 
2+  5  )   6  ) 
3+  6     7   
4+  7  func main() {   8  func main() { 
5+  …  @@ -9,5 +10,6 @@    …   
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.goldennew file+5
@@ -0,0 +1,5 @@
1+  5  )   6  ) 
2+  6     7   
3+  7  func main() {   8  func main() { 
4+  …  @@ -9,5 +10,6 @@    …   
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.goldennew file+5
@@ -0,0 +1,5 @@
1+  6     7   
2+  7  func main() {   8  func main() { 
3+  …  @@ -9,5 +10,6 @@    …   
4+  9  }  10  } 
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.goldennew file+5
@@ -0,0 +1,5 @@
1+  7  func main() {   8  func main() { 
2+  …  @@ -9,5 +10,6 @@    …   
3+  9  }  10  } 
4+ 10    11   
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.goldennew file+5
@@ -0,0 +1,5 @@
1+  …  @@ -9,5 +10,6 @@    …   
2+  9  }  10  } 
3+ 10    11   
4+ 11  func getContent() string {  12  func getContent() string { 
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.goldennew file+5
@@ -0,0 +1,5 @@
1+  9  }  10  } 
2+ 10    11   
3+ 11  func getContent() string {  12  func getContent() string { 
4+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
5+  …  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.goldennew file+5
@@ -0,0 +1,5 @@
1+ 10    11   
2+ 11  func getContent() string {  12  func getContent() string { 
3+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
4+      14 +  return content 
5+ 13  }  15  } 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.goldennew file+5
@@ -0,0 +1,5 @@
1+ 11  func getContent() string {  12  func getContent() string { 
2+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
3+      14 +  return content 
4+ 13  }  15  } 
5+          
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.goldennew file+5
@@ -0,0 +1,5 @@
1+ 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
2+      14 +  return content 
3+ 13  }  15  } 
4+          
5+          
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.goldennew file+5
@@ -0,0 +1,5 @@
1+      14 +  return content 
2+ 13  }  15  } 
3+          
4+          
5+          
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.goldennew file+5
@@ -0,0 +1,5 @@
1+ 13  }  15  } 
2+          
3+          
4+          
5+          
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.goldennew file+5
@@ -0,0 +1,5 @@
1+          
2+          
3+          
4+          
5+          
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.goldennew file+5
@@ -0,0 +1,5 @@
1+          
2+          
3+          
4+          
5+          
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.goldennew file+5
@@ -0,0 +1,5 @@
1+  …   …  @@ -2,6 +2,7 @@  
2+  2   2   
3+  3   3  import ( 
4+  4   4   "fmt" 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.goldennew file+5
@@ -0,0 +1,5 @@
1+  2   2   
2+  3   3  import ( 
3+  4   4   "fmt" 
4+     5 +  "strings" 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.goldennew file+5
@@ -0,0 +1,5 @@
1+  3   3  import ( 
2+  4   4   "fmt" 
3+     5 +  "strings" 
4+  5   6  ) 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.goldennew file+5
@@ -0,0 +1,5 @@
1+  4   4   "fmt" 
2+     5 +  "strings" 
3+  5   6  ) 
4+  6   7   
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.goldennew file+5
@@ -0,0 +1,5 @@
1+     5 +  "strings" 
2+  5   6  ) 
3+  6   7   
4+  7   8  func main() { 
5+  …   …  @@ -9,5 +10,6 @@  
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.goldennew file+5
@@ -0,0 +1,5 @@
1+  5   6  ) 
2+  6   7   
3+  7   8  func main() { 
4+  …   …  @@ -9,5 +10,6 @@  
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.goldennew file+5
@@ -0,0 +1,5 @@
1+  6   7   
2+  7   8  func main() { 
3+  …   …  @@ -9,5 +10,6 @@  
4+  9  10  } 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.goldennew file+5
@@ -0,0 +1,5 @@
1+  7   8  func main() { 
2+  …   …  @@ -9,5 +10,6 @@  
3+  9  10  } 
4+ 10  11   
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.goldennew file+5
@@ -0,0 +1,5 @@
1+  …   …  @@ -9,5 +10,6 @@  
2+  9  10  } 
3+ 10  11   
4+ 11  12  func getContent() string { 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.goldennew file+5
@@ -0,0 +1,5 @@
1+  9  10  } 
2+ 10  11   
3+ 11  12  func getContent() string { 
4+ 12    -  return "Hello, world!" 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.goldennew file+5
@@ -0,0 +1,5 @@
1+ 10  11   
2+ 11  12  func getContent() string { 
3+ 12    -  return "Hello, world!" 
4+    13 +  content := strings.ToUpper("Hello, World!") 
5+  …   …  … 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.goldennew file+5
@@ -0,0 +1,5 @@
1+ 11  12  func getContent() string { 
2+ 12    -  return "Hello, world!" 
3+    13 +  content := strings.ToUpper("Hello, World!") 
4+    14 +  return content 
5+ 13  15  } 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.goldennew file+5
@@ -0,0 +1,5 @@
1+ 12    -  return "Hello, world!" 
2+    13 +  content := strings.ToUpper("Hello, World!") 
3+    14 +  return content 
4+ 13  15  } 
5+        
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.goldennew file+5
@@ -0,0 +1,5 @@
1+    13 +  content := strings.ToUpper("Hello, World!") 
2+    14 +  return content 
3+ 13  15  } 
4+        
5+        
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.goldennew file+5
@@ -0,0 +1,5 @@
1+    14 +  return content 
2+ 13  15  } 
3+        
4+        
5+        
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.goldennew file+5
@@ -0,0 +1,5 @@
1+ 13  15  } 
2+        
3+        
4+        
5+        
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.goldennew file+5
@@ -0,0 +1,5 @@
1+        
2+        
3+        
4+        
5+        
internal/llm/agent/agent.go+8
@@ -50,6 +50,7 @@ type Service interface {
50 Model() models.Model
50 Model() models.Model
51 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
51 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
52 Cancel(sessionID string)
52 Cancel(sessionID string)
53+ CancelAll()
53 IsSessionBusy(sessionID string) bool
54 IsSessionBusy(sessionID string) bool
54 IsBusy() bool
55 IsBusy() bool
55 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
56 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
@@ -698,6 +699,13 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
698 return nil
699 return nil
699}
700}
700
701
702+func (a *agent) CancelAll() {
703+ a.activeRequests.Range(func(key, value any) bool {
704+ a.Cancel(key.(string)) // key is sessionID
705+ return true
706+ })
707+}
708+
701func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
709func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
702 cfg := config.Get()
710 cfg := config.Get()
703 agentConfig, ok := cfg.Agents[agentName]
711 agentConfig, ok := cfg.Agents[agentName]
internal/llm/prompt/coder.go+6−6
@@ -30,10 +30,6 @@ You are operating as and within the Crush CLI, a terminal-based agentic coding a
30You can:
30You can:
31- Receive user prompts, project context, and files.
31- Receive user prompts, project context, and files.
32- Stream responses and emit function calls (e.g., shell commands, code edits).
32- Stream responses and emit function calls (e.g., shell commands, code edits).
33−- Apply patches, run commands, and manage user approvals based on policy.
34−- Work inside a sandboxed, git-backed workspace with rollback support.
35−- Log telemetry so sessions can be replayed or inspected later.
36−- More details on your functionality are available at "crush --help"
37
33
38
34
39You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
35You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
@@ -64,17 +60,19 @@ You MUST adhere to the following criteria when executing the task:
64- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
60- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
65 - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
61 - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
66- When your task involves writing or modifying files:
62- When your task involves writing or modifying files:
67− - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "apply_patch". Instead, reference the file as already saved.
63+ - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "edit/write". Instead, reference the file as already saved.
68 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
64 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
69- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
65- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
70- If you send a path not including the working dir, the working dir will be prepended to it.
66- If you send a path not including the working dir, the working dir will be prepended to it.
71- Remember the user does not see the full output of tools
67- Remember the user does not see the full output of tools
68+- NEVER use emojis in your responses
72`
69`
73
70
74const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
71const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
75
72
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
73IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
77
74
75+
78# Memory
76# Memory
79If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
77If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
781. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
@@ -131,7 +129,7 @@ assistant: src/foo.c
131
129
132<example>
130<example>
133user: write tests for new feature
131user: write tests for new feature
134−assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests]
132+assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
135</example>
133</example>
136
134
137# Proactiveness
135# Proactiveness
@@ -165,6 +163,8 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
165- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
163- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
166- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
164- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
167
165
166+VERY IMPORTANT NEVER use emojis in your responses.
167+
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
169
169
170func getEnvironmentInfo() string {
170func getEnvironmentInfo() string {
internal/llm/tools/bash.go+2−1
@@ -36,6 +36,7 @@ const (
36 DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
36 DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
37 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
37 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
38 MaxOutputLength = 30000
38 MaxOutputLength = 30000
39+ BashNoOutput = "no output"
39)
40)
40
41
41var bannedCommands = []string{
42var bannedCommands = []string{
@@ -321,7 +322,7 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
321 EndTime: time.Now().UnixMilli(),
322 EndTime: time.Now().UnixMilli(),
322 }
323 }
323 if stdout == "" {
324 if stdout == "" {
324− return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
325+ return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
325 }
326 }
326 return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
327 return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
327}
328}
internal/tui/components/chat/chat.go+13−4
@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
272 assistantIndex = i
272 assistantIndex = i
273 }
273 }
274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
275− if tc.ParentMessageId() == messageID {
275+ if tc.ParentMessageID() == messageID {
276 toolCalls[i] = tc
276 toolCalls[i] = tc
277 }
277 }
278 }
278 }
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
295 assistantIndex,
295 assistantIndex,
296 messages.NewMessageCmp(
296 messages.NewMessageCmp(
297 msg,
297 msg,
298− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
299 ),
298 ),
300 )
299 )
300+
301+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
302+ m.listCmp.AppendItem(
303+ messages.NewAssistantSection(
304+ msg,
305+ time.Unix(m.lastUserMessageTime, 0),
306+ ),
307+ )
308+ }
301 } else if hasToolCallsOnly {
309 } else if hasToolCallsOnly {
302 m.listCmp.DeleteItem(assistantIndex)
310 m.listCmp.DeleteItem(assistantIndex)
303 }
311 }
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
347 cmd := m.listCmp.AppendItem(
355 cmd := m.listCmp.AppendItem(
348 messages.NewMessageCmp(
356 messages.NewMessageCmp(
349 msg,
357 msg,
350− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
351 ),
358 ),
352 )
359 )
353 cmds = append(cmds, cmd)
360 cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
412 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
419 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
413 case message.Assistant:
420 case message.Assistant:
414 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
421 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
422+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
423+ uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
424+ }
415 }
425 }
416 }
426 }
417
427
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
428 uiMessages,
438 uiMessages,
429 messages.NewMessageCmp(
439 messages.NewMessageCmp(
430 msg,
440 msg,
431− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
432 ),
441 ),
433 )
442 )
434 }
443 }
internal/tui/components/chat/messages/messages.go+64−44
@@ -8,13 +8,14 @@ import (
8
8
9 "github.com/charmbracelet/bubbles/v2/spinner"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/llm/models"
12 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
13
12
13+ "github.com/charmbracelet/crush/internal/llm/models"
14 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
18 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/util"
20 "github.com/charmbracelet/crush/internal/tui/util"
20)
21)
@@ -37,32 +38,17 @@ type messageCmp struct {
37 focused bool // Focus state for border styling
38 focused bool // Focus state for border styling
38
39
39 // Core message data and state
40 // Core message data and state
40− message message.Message // The underlying message content
41+ message message.Message // The underlying message content
41− spinning bool // Whether to show loading animation
42+ spinning bool // Whether to show loading animation
42− anim util.Model // Animation component for loading states
43+ anim util.Model // Animation component for loading states
43− lastUserMessageTime time.Time // Used for calculating response duration
44−}
45−
46−// MessageOption provides functional options for configuring message components
47−type MessageOption func(*messageCmp)
48−
49−// WithLastUserMessageTime sets the timestamp of the last user message
50−// for calculating assistant response duration
51−func WithLastUserMessageTime(t time.Time) MessageOption {
52− return func(m *messageCmp) {
53− m.lastUserMessageTime = t
54− }
55}
44}
56
45
57// NewMessageCmp creates a new message component with the given message and options
46// NewMessageCmp creates a new message component with the given message and options
58−func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
47+func NewMessageCmp(msg message.Message) MessageCmp {
59 m := &messageCmp{
48 m := &messageCmp{
60 message: msg,
49 message: msg,
61 anim: anim.New(15, ""),
50 anim: anim.New(15, ""),
62 }
51 }
63− for _, opt := range opts {
64− opt(m)
65− }
66 return m
52 return m
67}
53}
68
54
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
145// renderAssistantMessage renders assistant messages with optional footer information.
131// renderAssistantMessage renders assistant messages with optional footer information.
146// Shows model name, response time, and finish reason when the message is complete.
132// Shows model name, response time, and finish reason when the message is complete.
147func (m *messageCmp) renderAssistantMessage() string {
133func (m *messageCmp) renderAssistantMessage() string {
148− t := styles.CurrentTheme()
149 parts := []string{
134 parts := []string{
150 m.markdownContent(),
135 m.markdownContent(),
151 }
136 }
152
137
153− finished := m.message.IsFinished()
154− finishData := m.message.FinishPart()
155− // Only show the footer if the message is not a tool call
156− if finished && finishData.Reason != message.FinishReasonToolUse {
157− infoMsg := ""
158− switch finishData.Reason {
159− case message.FinishReasonEndTurn:
160− finishTime := time.Unix(finishData.Time, 0)
161− duration := finishTime.Sub(m.lastUserMessageTime)
162− infoMsg = duration.String()
163− case message.FinishReasonCanceled:
164− infoMsg = "canceled"
165− case message.FinishReasonError:
166− infoMsg = "error"
167− case message.FinishReasonPermissionDenied:
168− infoMsg = "permission denied"
169− }
170− assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
171− parts = append(parts, core.Section(assistant, m.textWidth()))
172− }
173−
174 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
138 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175 return m.style().Render(joined)
139 return m.style().Render(joined)
176}
140}
@@ -200,7 +164,7 @@ func (m *messageCmp) renderUserMessage() string {
200 parts = append(parts, "", strings.Join(attachments, ""))
164 parts = append(parts, "", strings.Join(attachments, ""))
201 }
165 }
202 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
166 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
203− return m.style().MarginBottom(1).Render(joined)
167+ return m.style().Render(joined)
204}
168}
205
169
206// toMarkdown converts text content to rendered markdown using the configured renderer
170// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
225 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
189 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226 // Sometimes the LLMs respond with no content when they think the previous tool result
190 // Sometimes the LLMs respond with no content when they think the previous tool result
227 // provides the requested question
191 // provides the requested question
228− content = "*Finished without output*"
192+ content = ""
229 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
193 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230 content = "*Canceled*"
194 content = "*Canceled*"
231 }
195 }
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
287func (m *messageCmp) Spinning() bool {
251func (m *messageCmp) Spinning() bool {
288 return m.spinning
252 return m.spinning
289}
253}
254+
255+type AssistantSection interface {
256+ util.Model
257+ layout.Sizeable
258+ list.SectionHeader
259+}
260+type assistantSectionModel struct {
261+ width int
262+ message message.Message
263+ lastUserMessageTime time.Time
264+}
265+
266+func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
267+ return &assistantSectionModel{
268+ width: 0,
269+ message: message,
270+ lastUserMessageTime: lastUserMessageTime,
271+ }
272+}
273+
274+func (m *assistantSectionModel) Init() tea.Cmd {
275+ return nil
276+}
277+
278+func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
279+ return m, nil
280+}
281+
282+func (m *assistantSectionModel) View() tea.View {
283+ t := styles.CurrentTheme()
284+ finishData := m.message.FinishPart()
285+ finishTime := time.Unix(finishData.Time, 0)
286+ duration := finishTime.Sub(m.lastUserMessageTime)
287+ infoMsg := t.S().Subtle.Render(duration.String())
288+ icon := t.S().Subtle.Render(styles.ModelIcon)
289+ model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
290+ assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
291+ return tea.NewView(
292+ t.S().Base.PaddingLeft(2).Render(
293+ core.Section(assistant, m.width-2),
294+ ),
295+ )
296+}
297+
298+func (m *assistantSectionModel) GetSize() (int, int) {
299+ return m.width, 1
300+}
301+
302+func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
303+ m.width = width
304+ return nil
305+}
306+
307+func (m *assistantSectionModel) IsSectionHeader() bool {
308+ return true
309+}
internal/tui/components/chat/messages/renderer.go+144−91
@@ -111,8 +111,18 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
111 return json.Unmarshal([]byte(input), target)
111 return json.Unmarshal([]byte(input), target)
112}
112}
113
113
114+// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
115+func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
116+ t := styles.CurrentTheme()
117+ tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
118+ return tool + renderParamList(true, width-lipgloss.Width(tool), params...)
119+}
120+
114// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
121// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
115func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
122func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
123+ if v.isNested {
124+ return br.makeNestedHeader(v, tool, width, params...)
125+ }
116 t := styles.CurrentTheme()
126 t := styles.CurrentTheme()
117 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
127 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
118 if v.result.ToolCallID != "" {
128 if v.result.ToolCallID != "" {
@@ -125,16 +135,17 @@ func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params
125 icon = t.S().Muted.Render(styles.ToolPending)
135 icon = t.S().Muted.Render(styles.ToolPending)
126 }
136 }
127 tool = t.S().Base.Foreground(t.Blue).Render(tool)
137 tool = t.S().Base.Foreground(t.Blue).Render(tool)
128− prefix := fmt.Sprintf("%s %s: ", icon, tool)
138+ prefix := fmt.Sprintf("%s %s ", icon, tool)
129− return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
139+ return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
130}
140}
131
141
132// renderError provides consistent error rendering
142// renderError provides consistent error rendering
133func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
143func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
134 t := styles.CurrentTheme()
144 t := styles.CurrentTheme()
135 header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
145 header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
146+ errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
136− message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
147+ message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
137− return joinHeaderBody(header, message)
148+ return joinHeaderBody(header, errorTag+" "+message)
138}
149}
139
150
140// Register tool renderers
151// Register tool renderers
@@ -188,6 +199,9 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
188 args := newParamBuilder().addMain(cmd).build()
199 args := newParamBuilder().addMain(cmd).build()
189
200
190 return br.renderWithParams(v, "Bash", args, func() string {
201 return br.renderWithParams(v, "Bash", args, func() string {
202+ if v.result.Content == tools.BashNoOutput {
203+ return ""
204+ }
191 return renderPlainContent(v, v.result.Content)
205 return renderPlainContent(v, v.result.Content)
192 })
206 })
193}
207}
@@ -244,13 +258,12 @@ type editRenderer struct {
244// Render displays the edited file with a formatted diff of changes
258// Render displays the edited file with a formatted diff of changes
245func (er editRenderer) Render(v *toolCallCmp) string {
259func (er editRenderer) Render(v *toolCallCmp) string {
246 var params tools.EditParams
260 var params tools.EditParams
261+ var args []string
247− if err := er.unmarshalParams(v.call.Input, &params); err != nil {
262+ if err := er.unmarshalParams(v.call.Input, &params); err == nil {
248− return er.renderError(v, "Invalid edit parameters")
263+ file := fsext.PrettyPath(params.FilePath)
264+ args = newParamBuilder().addMain(file).build()
249 }
265 }
250
266
251− file := fsext.PrettyPath(params.FilePath)
252− args := newParamBuilder().addMain(file).build()
253−
254 return er.renderWithParams(v, "Edit", args, func() string {
267 return er.renderWithParams(v, "Edit", args, func() string {
255 var meta tools.EditResponseMetadata
268 var meta tools.EditResponseMetadata
256 if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
269 if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
@@ -260,8 +273,10 @@ func (er editRenderer) Render(v *toolCallCmp) string {
260 formatter := core.DiffFormatter().
273 formatter := core.DiffFormatter().
261 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
274 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
262 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
275 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
263− Split().
264 Width(v.textWidth() - 2) // -2 for padding
276 Width(v.textWidth() - 2) // -2 for padding
277+ if v.textWidth() > 120 {
278+ formatter = formatter.Split()
279+ }
265 return formatter.String()
280 return formatter.String()
266 })
281 })
267}
282}
@@ -278,13 +293,13 @@ type writeRenderer struct {
278// Render displays the file being written with syntax highlighting
293// Render displays the file being written with syntax highlighting
279func (wr writeRenderer) Render(v *toolCallCmp) string {
294func (wr writeRenderer) Render(v *toolCallCmp) string {
280 var params tools.WriteParams
295 var params tools.WriteParams
296+ var args []string
297+ var file string
281− if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
298+ if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
282− return wr.renderError(v, "Invalid write parameters")
299+ file = fsext.PrettyPath(params.FilePath)
300+ args = newParamBuilder().addMain(file).build()
283 }
301 }
284
302
285− file := fsext.PrettyPath(params.FilePath)
286− args := newParamBuilder().addMain(file).build()
287−
288 return wr.renderWithParams(v, "Write", args, func() string {
303 return wr.renderWithParams(v, "Write", args, func() string {
289 return renderCodeContent(v, file, params.Content, 0)
304 return renderCodeContent(v, file, params.Content, 0)
290 })
305 })
@@ -302,16 +317,15 @@ type fetchRenderer struct {
302// Render displays the fetched URL with format and timeout parameters
317// Render displays the fetched URL with format and timeout parameters
303func (fr fetchRenderer) Render(v *toolCallCmp) string {
318func (fr fetchRenderer) Render(v *toolCallCmp) string {
304 var params tools.FetchParams
319 var params tools.FetchParams
320+ var args []string
305− if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
321+ if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
306− return fr.renderError(v, "Invalid fetch parameters")
322+ args = newParamBuilder().
323+ addMain(params.URL).
324+ addKeyValue("format", params.Format).
325+ addKeyValue("timeout", formatTimeout(params.Timeout)).
326+ build()
307 }
327 }
308
328
309− args := newParamBuilder().
310− addMain(params.URL).
311− addKeyValue("format", params.Format).
312− addKeyValue("timeout", formatTimeout(params.Timeout)).
313− build()
314−
315 return fr.renderWithParams(v, "Fetch", args, func() string {
329 return fr.renderWithParams(v, "Fetch", args, func() string {
316 file := fr.getFileExtension(params.Format)
330 file := fr.getFileExtension(params.Format)
317 return renderCodeContent(v, file, v.result.Content, 0)
331 return renderCodeContent(v, file, v.result.Content, 0)
@@ -350,15 +364,14 @@ type globRenderer struct {
350// Render displays the glob pattern with optional path parameter
364// Render displays the glob pattern with optional path parameter
351func (gr globRenderer) Render(v *toolCallCmp) string {
365func (gr globRenderer) Render(v *toolCallCmp) string {
352 var params tools.GlobParams
366 var params tools.GlobParams
367+ var args []string
353− if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
368+ if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
354− return gr.renderError(v, "Invalid glob parameters")
369+ args = newParamBuilder().
370+ addMain(params.Pattern).
371+ addKeyValue("path", params.Path).
372+ build()
355 }
373 }
356
374
357− args := newParamBuilder().
358− addMain(params.Pattern).
359− addKeyValue("path", params.Path).
360− build()
361−
362 return gr.renderWithParams(v, "Glob", args, func() string {
375 return gr.renderWithParams(v, "Glob", args, func() string {
363 return renderPlainContent(v, v.result.Content)
376 return renderPlainContent(v, v.result.Content)
364 })
377 })
@@ -376,17 +389,16 @@ type grepRenderer struct {
376// Render displays the search pattern with path, include, and literal text options
389// Render displays the search pattern with path, include, and literal text options
377func (gr grepRenderer) Render(v *toolCallCmp) string {
390func (gr grepRenderer) Render(v *toolCallCmp) string {
378 var params tools.GrepParams
391 var params tools.GrepParams
392+ var args []string
379− if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
393+ if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
380− return gr.renderError(v, "Invalid grep parameters")
394+ args = newParamBuilder().
395+ addMain(params.Pattern).
396+ addKeyValue("path", params.Path).
397+ addKeyValue("include", params.Include).
398+ addFlag("literal", params.LiteralText).
399+ build()
381 }
400 }
382
401
383− args := newParamBuilder().
384− addMain(params.Pattern).
385− addKeyValue("path", params.Path).
386− addKeyValue("include", params.Include).
387− addFlag("literal", params.LiteralText).
388− build()
389−
390 return gr.renderWithParams(v, "Grep", args, func() string {
402 return gr.renderWithParams(v, "Grep", args, func() string {
391 return renderPlainContent(v, v.result.Content)
403 return renderPlainContent(v, v.result.Content)
392 })
404 })
@@ -404,17 +416,16 @@ type lsRenderer struct {
404// Render displays the directory path, defaulting to current directory
416// Render displays the directory path, defaulting to current directory
405func (lr lsRenderer) Render(v *toolCallCmp) string {
417func (lr lsRenderer) Render(v *toolCallCmp) string {
406 var params tools.LSParams
418 var params tools.LSParams
419+ var args []string
407− if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
420+ if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
408− return lr.renderError(v, "Invalid ls parameters")
421+ path := params.Path
422+ if path == "" {
423+ path = "."
409− }
424+ }
425+ path = fsext.PrettyPath(path)
410
426
411− path := params.Path
427+ args = newParamBuilder().addMain(path).build()
412− if path == "" {
413− path = "."
414 }
428 }
415− path = fsext.PrettyPath(path)
416−
417− args := newParamBuilder().addMain(path).build()
418
429
419 return lr.renderWithParams(v, "List", args, func() string {
430 return lr.renderWithParams(v, "List", args, func() string {
420 return renderPlainContent(v, v.result.Content)
431 return renderPlainContent(v, v.result.Content)
@@ -433,16 +444,15 @@ type sourcegraphRenderer struct {
433// Render displays the search query with optional count and context window parameters
444// Render displays the search query with optional count and context window parameters
434func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
445func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
435 var params tools.SourcegraphParams
446 var params tools.SourcegraphParams
447+ var args []string
436− if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
448+ if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
437− return sr.renderError(v, "Invalid sourcegraph parameters")
449+ args = newParamBuilder().
450+ addMain(params.Query).
451+ addKeyValue("count", formatNonZero(params.Count)).
452+ addKeyValue("context", formatNonZero(params.ContextWindow)).
453+ build()
438 }
454 }
439
455
440− args := newParamBuilder().
441− addMain(params.Query).
442− addKeyValue("count", formatNonZero(params.Count)).
443− addKeyValue("context", formatNonZero(params.ContextWindow)).
444− build()
445−
446 return sr.renderWithParams(v, "Sourcegraph", args, func() string {
456 return sr.renderWithParams(v, "Sourcegraph", args, func() string {
447 return renderPlainContent(v, v.result.Content)
457 return renderPlainContent(v, v.result.Content)
448 })
458 })
@@ -475,25 +485,47 @@ type agentRenderer struct {
475 baseRenderer
485 baseRenderer
476}
486}
477
487
488+func RoundedEnumerator(children tree.Children, index int) string {
489+ if children.Length()-1 == index {
490+ return " ╰──"
491+ }
492+ return " ├──"
493+}
494+
478// Render displays agent task parameters and result content
495// Render displays agent task parameters and result content
479func (tr agentRenderer) Render(v *toolCallCmp) string {
496func (tr agentRenderer) Render(v *toolCallCmp) string {
497+ t := styles.CurrentTheme()
480 var params agent.AgentParams
498 var params agent.AgentParams
481− if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
499+ tr.unmarshalParams(v.call.Input, &params)
482− return tr.renderError(v, "Invalid task parameters")
500+
483− }
484 prompt := params.Prompt
501 prompt := params.Prompt
485 prompt = strings.ReplaceAll(prompt, "\n", " ")
502 prompt = strings.ReplaceAll(prompt, "\n", " ")
486− args := newParamBuilder().addMain(prompt).build()
487
503
488− header := tr.makeHeader(v, "Task", v.textWidth(), args...)
504+ header := tr.makeHeader(v, "Agent", v.textWidth())
505+ if res, done := earlyState(header, v); done {
506+ return res
507+ }
508+ taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
509+ remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 // -2 for padding
510+ prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
511+ header = lipgloss.JoinVertical(
512+ lipgloss.Left,
513+ header,
514+ "",
515+ lipgloss.JoinHorizontal(
516+ lipgloss.Left,
517+ taskTag,
518+ " ",
519+ prompt,
520+ ),
521+ )
489− t := tree.Root(header)
522+ childTools := tree.Root(header)
490
523
491 for _, call := range v.nestedToolCalls {
524 for _, call := range v.nestedToolCalls {
492− t.Child(call.View())
525+ childTools.Child(call.View())
493 }
526 }
494−
495 parts := []string{
527 parts := []string{
496− t.Enumerator(tree.RoundedEnumerator).String(),
528+ childTools.Enumerator(RoundedEnumerator).String(),
497 }
529 }
498 if v.result.ToolCallID == "" {
530 if v.result.ToolCallID == "" {
499 v.spinning = true
531 v.spinning = true
@@ -516,7 +548,8 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
516}
548}
517
549
518// renderParamList renders params, params[0] (params[1]=params[2] ....)
550// renderParamList renders params, params[0] (params[1]=params[2] ....)
519−func renderParamList(paramsWidth int, params ...string) string {
551+func renderParamList(nested bool, paramsWidth int, params ...string) string {
552+ t := styles.CurrentTheme()
520 if len(params) == 0 {
553 if len(params) == 0 {
521 return ""
554 return ""
522 }
555 }
@@ -526,7 +559,10 @@ func renderParamList(paramsWidth int, params ...string) string {
526 }
559 }
527
560
528 if len(params) == 1 {
561 if len(params) == 1 {
562+ if nested {
529− return mainParam
563+ return t.S().Muted.Render(mainParam)
564+ }
565+ return t.S().Subtle.Render(mainParam)
530 }
566 }
531 otherParams := params[1:]
567 otherParams := params[1:]
532 // create pairs of key/value
568 // create pairs of key/value
@@ -547,15 +583,21 @@ func renderParamList(paramsWidth int, params ...string) string {
547 partsRendered := strings.Join(parts, ", ")
583 partsRendered := strings.Join(parts, ", ")
548 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
584 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
549 if remainingWidth < 30 {
585 if remainingWidth < 30 {
586+ if nested {
587+ return t.S().Muted.Render(mainParam)
588+ }
550 // No space for the params, just show the main
589 // No space for the params, just show the main
551− return mainParam
590+ return t.S().Subtle.Render(mainParam)
552 }
591 }
553
592
554 if len(parts) > 0 {
593 if len(parts) > 0 {
555 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
594 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
556 }
595 }
557
596
597+ if nested {
598+ return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
599+ }
558− return ansi.Truncate(mainParam, paramsWidth, "...")
600+ return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
559}
601}
560
602
561// earlyState returns immediately‑rendered error/cancelled/ongoing states.
603// earlyState returns immediately‑rendered error/cancelled/ongoing states.
@@ -566,21 +608,21 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
566 case v.result.IsError:
608 case v.result.IsError:
567 message = v.renderToolError()
609 message = v.renderToolError()
568 case v.cancelled:
610 case v.cancelled:
569− message = "Cancelled"
611+ message = t.S().Base.Padding(0, 1).Background(t.Border).Render("Cancelled")
570 case v.result.ToolCallID == "":
612 case v.result.ToolCallID == "":
571− message = "Waiting for tool to start..."
613+ message = t.S().Base.Padding(0, 1).Background(t.Accent).Foreground(t.FgSubtle).Render("Waiting for tool to start...")
572 default:
614 default:
573 return "", false
615 return "", false
574 }
616 }
575
617
576 message = t.S().Base.PaddingLeft(2).Render(message)
618 message = t.S().Base.PaddingLeft(2).Render(message)
577− return lipgloss.JoinVertical(lipgloss.Left, header, message), true
619+ return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
578}
620}
579
621
580func joinHeaderBody(header, body string) string {
622func joinHeaderBody(header, body string) string {
581 t := styles.CurrentTheme()
623 t := styles.CurrentTheme()
582 body = t.S().Base.PaddingLeft(2).Render(body)
624 body = t.S().Base.PaddingLeft(2).Render(body)
583− return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
625+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
584}
626}
585
627
586func renderPlainContent(v *toolCallCmp, content string) string {
628func renderPlainContent(v *toolCallCmp, content string) string {
@@ -600,46 +642,56 @@ func renderPlainContent(v *toolCallCmp, content string) string {
600 }
642 }
601 out = append(out, t.S().Muted.
643 out = append(out, t.S().Muted.
602 Width(width).
644 Width(width).
603− Background(t.BgSubtle).
645+ Background(t.BgBaseLighter).
604 Render(ln))
646 Render(ln))
605 }
647 }
606
648
607 if len(lines) > responseContextHeight {
649 if len(lines) > responseContextHeight {
608 out = append(out, t.S().Muted.
650 out = append(out, t.S().Muted.
609− Background(t.BgSubtle).
651+ Background(t.BgBaseLighter).
610 Width(width).
652 Width(width).
611 Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
653 Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
612 }
654 }
613 return strings.Join(out, "\n")
655 return strings.Join(out, "\n")
614}
656}
615
657
658+func pad(v any, width int) string {
659+ s := fmt.Sprintf("%v", v)
660+ w := ansi.StringWidth(s)
661+ if w >= width {
662+ return s
663+ }
664+ return strings.Repeat(" ", width-w) + s
665+}
666+
616func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
667func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
617 t := styles.CurrentTheme()
668 t := styles.CurrentTheme()
618 truncated := truncateHeight(content, responseContextHeight)
669 truncated := truncateHeight(content, responseContextHeight)
619
670
620− highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
671+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
621 lines := strings.Split(highlighted, "\n")
672 lines := strings.Split(highlighted, "\n")
622
673
623 if len(strings.Split(content, "\n")) > responseContextHeight {
674 if len(strings.Split(content, "\n")) > responseContextHeight {
624 lines = append(lines, t.S().Muted.
675 lines = append(lines, t.S().Muted.
625− Background(t.BgSubtle).
676+ Background(t.BgBase).
626− Width(v.textWidth()-2).
627− Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
677+ Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
628 }
678 }
629
679
680+ maxLineNumber := len(lines) + offset
681+ padding := lipgloss.Width(fmt.Sprintf("%d", maxLineNumber))
630 for i, ln := range lines {
682 for i, ln := range lines {
631− num := t.S().Muted.
683+ num := t.S().Base.
684+ Foreground(t.FgMuted).
632− Background(t.BgSubtle).
685+ Background(t.BgBase).
633− PaddingLeft(4).
634− PaddingRight(2).
686+ PaddingRight(1).
687+ PaddingLeft(1).
635− Render(fmt.Sprintf("%d", i+1+offset))
688+ Render(pad(i+1+offset, padding))
636− w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
689+ w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding
637 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
690 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
638 num,
691 num,
639 t.S().Base.
692 t.S().Base.
640− Width(w).
693+ PaddingLeft(1).
641− Background(t.BgSubtle).
642− Render(v.fit(ln, w)))
694+ Render(v.fit(ln, w-1)))
643 }
695 }
644 return lipgloss.JoinVertical(lipgloss.Left, lines...)
696 return lipgloss.JoinVertical(lipgloss.Left, lines...)
645}
697}
@@ -647,8 +699,9 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
647func (v *toolCallCmp) renderToolError() string {
699func (v *toolCallCmp) renderToolError() string {
648 t := styles.CurrentTheme()
700 t := styles.CurrentTheme()
649 err := strings.ReplaceAll(v.result.Content, "\n", " ")
701 err := strings.ReplaceAll(v.result.Content, "\n", " ")
650− err = fmt.Sprintf("Error: %s", err)
702+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
651− return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
703+ err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
704+ return err
652}
705}
653
706
654func truncateHeight(s string, h int) string {
707func truncateHeight(s string, h int) string {
@@ -662,7 +715,7 @@ func truncateHeight(s string, h int) string {
662func prettifyToolName(name string) string {
715func prettifyToolName(name string) string {
663 switch name {
716 switch name {
664 case agent.AgentToolName:
717 case agent.AgentToolName:
665− return "Task"
718+ return "Agent"
666 case tools.BashToolName:
719 case tools.BashToolName:
667 return "Bash"
720 return "Bash"
668 case tools.EditToolName:
721 case tools.EditToolName:
internal/tui/components/chat/messages/tool.go+15−11
@@ -25,7 +25,7 @@ type ToolCallCmp interface {
25 SetToolResult(message.ToolResult) // Update tool result
25 SetToolResult(message.ToolResult) // Update tool result
26 SetToolCall(message.ToolCall) // Update tool call
26 SetToolCall(message.ToolCall) // Update tool call
27 SetCancelled() // Mark as cancelled
27 SetCancelled() // Mark as cancelled
28− ParentMessageId() string // Get parent message ID
28+ ParentMessageID() string // Get parent message ID
29 Spinning() bool // Animation state for pending tools
29 Spinning() bool // Animation state for pending tools
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
@@ -83,10 +83,10 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
83
83
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
85// tool call, and optional configuration
85// tool call, and optional configuration
86−func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
86+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
87 m := &toolCallCmp{
87 m := &toolCallCmp{
88 call: tc,
88 call: tc,
89− parentMessageID: parentMessageId,
89+ parentMessageID: parentMessageID,
90 }
90 }
91 for _, opt := range opts {
91 for _, opt := range opts {
92 opt(m)
92 opt(m)
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
137 box := m.style()
137 box := m.style()
138
138
139 if !m.call.Finished && !m.cancelled {
139 if !m.call.Finished && !m.cancelled {
140− if m.isNested {
141− return tea.NewView(box.Render(m.renderPending()))
142− }
143 return tea.NewView(box.Render(m.renderPending()))
140 return tea.NewView(box.Render(m.renderPending()))
144 }
141 }
145
142
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
166 }
163 }
167}
164}
168
165
169−// ParentMessageId returns the ID of the message that initiated this tool call
166+// ParentMessageID returns the ID of the message that initiated this tool call
170−func (m *toolCallCmp) ParentMessageId() string {
167+func (m *toolCallCmp) ParentMessageID() string {
171 return m.parentMessageID
168 return m.parentMessageID
172}
169}
173
170
@@ -210,9 +207,13 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
210// renderPending displays the tool name with a loading animation for pending tool calls
207// renderPending displays the tool name with a loading animation for pending tool calls
211func (m *toolCallCmp) renderPending() string {
208func (m *toolCallCmp) renderPending() string {
212 t := styles.CurrentTheme()
209 t := styles.CurrentTheme()
210+ if m.isNested {
211+ tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
212+ return fmt.Sprintf("%s %s", tool, m.anim.View())
213+ }
213 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215− return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
216+ return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
216}
217}
217
218
218// style returns the lipgloss style for the tool call component.
219// style returns the lipgloss style for the tool call component.
@@ -229,14 +230,17 @@ func (m *toolCallCmp) style() lipgloss.Style {
229// textWidth calculates the available width for text content,
230// textWidth calculates the available width for text content,
230// accounting for borders and padding
231// accounting for borders and padding
231func (m *toolCallCmp) textWidth() int {
232func (m *toolCallCmp) textWidth() int {
233+ if m.isNested {
234+ return m.width - 6
235+ }
232 return m.width - 5 // take into account the border and PaddingLeft
236 return m.width - 5 // take into account the border and PaddingLeft
233}
237}
234
238
235// fit truncates content to fit within the specified width with ellipsis
239// fit truncates content to fit within the specified width with ellipsis
236func (m *toolCallCmp) fit(content string, width int) string {
240func (m *toolCallCmp) fit(content string, width int) string {
237 t := styles.CurrentTheme()
241 t := styles.CurrentTheme()
238− lineStyle := t.S().Muted.Background(t.BgSubtle)
242+ lineStyle := t.S().Muted
239− dots := lineStyle.Render("...")
243+ dots := lineStyle.Render("…")
240 return ansi.Truncate(content, width, dots)
244 return ansi.Truncate(content, width, dots)
241}
245}
242
246
internal/tui/components/chat/sidebar/sidebar.go+3−3
@@ -288,9 +288,9 @@ func (m *sidebarCmp) filesBlock() string {
288 })
288 })
289
289
290 for _, file := range files {
290 for _, file := range files {
291− // Extract just the filename from the path
291+ if file.Additions == 0 && file.Deletions == 0 {
292−
292+ continue // skip files with no changes
293− // Create status indicators for additions/deletions
293+ }
294 var statusParts []string
294 var statusParts []string
295 if file.Additions > 0 {
295 if file.Additions > 0 {
296 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
296 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
internal/tui/components/core/helpers.go+2−1
@@ -148,8 +148,9 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
148}
148}
149
149
150func DiffFormatter() *diffview.DiffView {
150func DiffFormatter() *diffview.DiffView {
151+ t := styles.CurrentTheme()
151 formatDiff := diffview.New()
152 formatDiff := diffview.New()
152 style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
153 style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
153− diff := formatDiff.ChromaStyle(style)
154+ diff := formatDiff.ChromaStyle(style).Style(t.S().Diff)
154 return diff
155 return diff
155}
156}
internal/tui/components/core/list/keys.go+3−3
@@ -32,10 +32,10 @@ func DefaultKeyMap() KeyMap {
32 key.WithKeys("k"),
32 key.WithKeys("k"),
33 ),
33 ),
34 UpOneItem: key.NewBinding(
34 UpOneItem: key.NewBinding(
35− key.WithKeys("shift+up", "shift+k"),
35+ key.WithKeys("shift+up", "K"),
36 ),
36 ),
37 DownOneItem: key.NewBinding(
37 DownOneItem: key.NewBinding(
38− key.WithKeys("shift+down", "shift+j"),
38+ key.WithKeys("shift+down", "J"),
39 ),
39 ),
40 HalfPageDown: key.NewBinding(
40 HalfPageDown: key.NewBinding(
41 key.WithKeys("d"),
41 key.WithKeys("d"),
@@ -47,7 +47,7 @@ func DefaultKeyMap() KeyMap {
47 key.WithKeys("g", "home"),
47 key.WithKeys("g", "home"),
48 ),
48 ),
49 End: key.NewBinding(
49 End: key.NewBinding(
50− key.WithKeys("shift+g", "end"),
50+ key.WithKeys("G", "end"),
51 ),
51 ),
52 }
52 }
53}
53}
internal/tui/components/core/list/list.go+4−2
@@ -749,8 +749,8 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
749func (m *model) goToBottom() tea.Cmd {
749func (m *model) goToBottom() tea.Cmd {
750 cmds := []tea.Cmd{m.blurSelected()}
750 cmds := []tea.Cmd{m.blurSelected()}
751 m.viewState.reverse = true
751 m.viewState.reverse = true
752+ m.selectionState.selectedIndex = m.findLastSelectableItem()
752 if m.isFocused {
753 if m.isFocused {
753− m.selectionState.selectedIndex = m.findLastSelectableItem()
754 cmds = append(cmds, m.focusSelected())
754 cmds = append(cmds, m.focusSelected())
755 }
755 }
756 m.ResetView()
756 m.ResetView()
@@ -764,7 +764,9 @@ func (m *model) goToTop() tea.Cmd {
764 cmds := []tea.Cmd{m.blurSelected()}
764 cmds := []tea.Cmd{m.blurSelected()}
765 m.viewState.reverse = false
765 m.viewState.reverse = false
766 m.selectionState.selectedIndex = m.findFirstSelectableItem()
766 m.selectionState.selectedIndex = m.findFirstSelectableItem()
767+ if m.isFocused {
767− cmds = append(cmds, m.focusSelected())
768+ cmds = append(cmds, m.focusSelected())
769+ }
768 m.ResetView()
770 m.ResetView()
769 return tea.Batch(cmds...)
771 return tea.Batch(cmds...)
770}
772}
internal/tui/components/core/status/status.go+27−9
@@ -1,6 +1,7 @@
1package status
1package status
2
2
3import (
3import (
4+ "strings"
4 "time"
5 "time"
5
6
6 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
@@ -10,6 +11,8 @@ import (
10 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
15+ "github.com/charmbracelet/x/ansi"
13)
16)
14
17
15type StatusCmp interface {
18type StatusCmp interface {
@@ -85,6 +88,7 @@ func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85 TTL: msg.Payload.PersistTime,
88 TTL: msg.Payload.PersistTime,
86 }
89 }
87 }
90 }
91+ return m, m.clearMessageCmd(m.info.TTL)
88 }
92 }
89 }
93 }
90 return m, nil
94 return m, nil
@@ -94,18 +98,32 @@ func (m *statusCmp) View() tea.View {
94 t := styles.CurrentTheme()
98 t := styles.CurrentTheme()
95 status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
99 status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
96 if m.info.Msg != "" {
100 if m.info.Msg != "" {
97− switch m.info.Type {
101+ status = m.infoMsg()
98− case util.InfoTypeError:
99− status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
100− case util.InfoTypeWarn:
101− status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
102− default:
103− status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
104− }
105 }
102 }
106 return tea.NewView(status)
103 return tea.NewView(status)
107}
104}
108
105
106+func (m *statusCmp) infoMsg() string {
107+ t := styles.CurrentTheme()
108+ message := ""
109+ infoType := ""
110+ switch m.info.Type {
111+ case util.InfoTypeError:
112+ infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
113+ width := m.width - lipgloss.Width(infoType)
114+ message = t.S().Base.Background(t.Error).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
115+ case util.InfoTypeWarn:
116+ infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
117+ width := m.width - lipgloss.Width(infoType)
118+ message = t.S().Base.Foreground(t.BgOverlay).Background(t.Warning).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
119+ default:
120+ infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
121+ width := m.width - lipgloss.Width(infoType)
122+ message = t.S().Base.Background(t.Success).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
123+ }
124+ return strings.Join([]string{infoType, message}, "")
125+}
126+
109func (m *statusCmp) ToggleFullHelp() {
127func (m *statusCmp) ToggleFullHelp() {
110 m.help.ShowAll = !m.help.ShowAll
128 m.help.ShowAll = !m.help.ShowAll
111}
129}
@@ -119,7 +137,7 @@ func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
119 help := help.New()
137 help := help.New()
120 help.Styles = t.S().Help
138 help.Styles = t.S().Help
121 return &statusCmp{
139 return &statusCmp{
122− messageTTL: 10 * time.Second,
140+ messageTTL: 5 * time.Second,
123 help: help,
141 help: help,
124 keyMap: keyMap,
142 keyMap: keyMap,
125 }
143 }
internal/tui/components/dialogs/commands/arguments.go−9
@@ -211,15 +211,6 @@ func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
211 return cursor
211 return cursor
212}
212}
213
213
214−func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
215− t := styles.CurrentTheme()
216− return t.S().Base.
217− Width(c.width).
218− Padding(1).
219− Border(lipgloss.RoundedBorder()).
220− BorderForeground(t.BorderFocus)
221−}
222−
223func (c *commandArgumentsDialogCmp) Position() (int, int) {
214func (c *commandArgumentsDialogCmp) Position() (int, int) {
224 row := c.wHeight / 2
215 row := c.wHeight / 2
225 row -= c.wHeight / 2
216 row -= c.wHeight / 2
internal/tui/components/dialogs/models/models.go+4−4
@@ -193,15 +193,15 @@ func (m *modelDialogCmp) listHeight() int {
193
193
194func GetSelectedModel(cfg *config.Config) models.Model {
194func GetSelectedModel(cfg *config.Config) models.Model {
195 agentCfg := cfg.Agents[config.AgentCoder]
195 agentCfg := cfg.Agents[config.AgentCoder]
196− selectedModelId := agentCfg.Model
196+ selectedModelID := agentCfg.Model
197− return models.SupportedModels[selectedModelId]
197+ return models.SupportedModels[selectedModelID]
198}
198}
199
199
200func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
200func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
201 var providers []models.ModelProvider
201 var providers []models.ModelProvider
202− for providerId, provider := range cfg.Providers {
202+ for providerID, provider := range cfg.Providers {
203 if !provider.Disabled {
203 if !provider.Disabled {
204− providers = append(providers, providerId)
204+ providers = append(providers, providerID)
205 }
205 }
206 }
206 }
207
207
internal/tui/components/dialogs/permissions/keys.go+41−6
@@ -11,7 +11,12 @@ type KeyMap struct {
11 Select,
11 Select,
12 Allow,
12 Allow,
13 AllowSession,
13 AllowSession,
14+ Deny,
15+ ToggleDiffMode,
16+ ScrollDown,
17+ ScrollUp key.Binding
18+ ScrollLeft,
14− Deny key.Binding
19+ ScrollRight key.Binding
15}
20}
16
21
17func DefaultKeyMap() KeyMap {
22func DefaultKeyMap() KeyMap {
@@ -41,9 +46,29 @@ func DefaultKeyMap() KeyMap {
41 key.WithHelp("d", "deny"),
46 key.WithHelp("d", "deny"),
42 ),
47 ),
43 Select: key.NewBinding(
48 Select: key.NewBinding(
44− key.WithKeys("enter", "tab", "ctrl+y"),
49+ key.WithKeys("enter", "ctrl+y"),
45 key.WithHelp("enter", "confirm"),
50 key.WithHelp("enter", "confirm"),
46 ),
51 ),
52+ ToggleDiffMode: key.NewBinding(
53+ key.WithKeys("t"),
54+ key.WithHelp("t", "toggle diff mode"),
55+ ),
56+ ScrollDown: key.NewBinding(
57+ key.WithKeys("shift+down", "J"),
58+ key.WithHelp("shift+↓", "scroll down"),
59+ ),
60+ ScrollUp: key.NewBinding(
61+ key.WithKeys("shift+up", "K"),
62+ key.WithHelp("shift+↑", "scroll up"),
63+ ),
64+ ScrollLeft: key.NewBinding(
65+ key.WithKeys("shift+left", "H"),
66+ key.WithHelp("shift+←", "scroll left"),
67+ ),
68+ ScrollRight: key.NewBinding(
69+ key.WithKeys("shift+right", "L"),
70+ key.WithHelp("shift+→", "scroll right"),
71+ ),
47 }
72 }
48}
73}
49
74
@@ -57,6 +82,11 @@ func (k KeyMap) KeyBindings() []key.Binding {
57 k.Allow,
82 k.Allow,
58 k.AllowSession,
83 k.AllowSession,
59 k.Deny,
84 k.Deny,
85+ k.ToggleDiffMode,
86+ k.ScrollDown,
87+ k.ScrollUp,
88+ k.ScrollLeft,
89+ k.ScrollRight,
60 }
90 }
61}
91}
62
92
@@ -74,9 +104,14 @@ func (k KeyMap) FullHelp() [][]key.Binding {
74// ShortHelp implements help.KeyMap.
104// ShortHelp implements help.KeyMap.
75func (k KeyMap) ShortHelp() []key.Binding {
105func (k KeyMap) ShortHelp() []key.Binding {
76 return []key.Binding{
106 return []key.Binding{
77− k.Allow,
107+ k.ToggleDiffMode,
78− k.AllowSession,
108+ key.NewBinding(
79− k.Deny,
109+ key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
80− k.Select,
110+ key.WithHelp("shift+←↓↑→", "scroll"),
111+ ),
112+ key.NewBinding(
113+ key.WithKeys("shift+h", "shift+j", "shift+k", "shift+l"),
114+ key.WithHelp("shift+hjkl", "scroll"),
115+ ),
81 }
116 }
82}
117}
internal/tui/components/dialogs/permissions/permissions.go+37−4
@@ -4,6 +4,7 @@ import (
4 "fmt"
4 "fmt"
5 "strings"
5 "strings"
6
6
7+ "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/viewport"
9 "github.com/charmbracelet/bubbles/v2/viewport"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
@@ -50,6 +51,11 @@ type permissionDialogCmp struct {
50 contentViewPort viewport.Model
51 contentViewPort viewport.Model
51 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
52 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
52
53
54+ // Diff view state
55+ diffSplitMode bool // true for split, false for unified
56+ diffXOffset int // horizontal scroll offset
57+ diffYOffset int // vertical scroll offset
58+
53 keyMap KeyMap
59 keyMap KeyMap
54}
60}
55
61
@@ -101,6 +107,21 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 util.CmdHandler(dialogs.CloseDialogMsg{}),
107 util.CmdHandler(dialogs.CloseDialogMsg{}),
102 util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
108 util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
103 )
109 )
110+ case key.Matches(msg, p.keyMap.ToggleDiffMode):
111+ p.diffSplitMode = !p.diffSplitMode
112+ return p, nil
113+ case key.Matches(msg, p.keyMap.ScrollDown):
114+ p.diffYOffset += 1
115+ return p, nil
116+ case key.Matches(msg, p.keyMap.ScrollUp):
117+ p.diffYOffset = max(0, p.diffYOffset-1)
118+ return p, nil
119+ case key.Matches(msg, p.keyMap.ScrollLeft):
120+ p.diffXOffset = max(0, p.diffXOffset-5)
121+ return p, nil
122+ case key.Matches(msg, p.keyMap.ScrollRight):
123+ p.diffXOffset += 5
124+ return p, nil
104 default:
125 default:
105 // Pass other keys to viewport
126 // Pass other keys to viewport
106 viewPort, cmd := p.contentViewPort.Update(msg)
127 viewPort, cmd := p.contentViewPort.Update(msg)
@@ -269,8 +290,15 @@ func (p *permissionDialogCmp) renderEditContent() string {
269 formatter := core.DiffFormatter().
290 formatter := core.DiffFormatter().
270 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
291 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
271 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
292 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
293+ Height(p.contentViewPort.Height()).
272 Width(p.contentViewPort.Width()).
294 Width(p.contentViewPort.Width()).
273− Split()
295+ XOffset(p.diffXOffset).
296+ YOffset(p.diffYOffset)
297+ if p.diffSplitMode {
298+ formatter = formatter.Split()
299+ } else {
300+ formatter = formatter.Unified()
301+ }
274
302
275 diff := formatter.String()
303 diff := formatter.String()
276 contentHeight := min(p.height-9, lipgloss.Height(diff))
304 contentHeight := min(p.height-9, lipgloss.Height(diff))
@@ -367,11 +395,13 @@ func (p *permissionDialogCmp) render() string {
367
395
368 // Render content based on tool type
396 // Render content based on tool type
369 var contentFinal string
397 var contentFinal string
398+ var contentHelp string
370 switch p.permission.ToolName {
399 switch p.permission.ToolName {
371 case tools.BashToolName:
400 case tools.BashToolName:
372 contentFinal = p.renderBashContent()
401 contentFinal = p.renderBashContent()
373 case tools.EditToolName:
402 case tools.EditToolName:
374 contentFinal = p.renderEditContent()
403 contentFinal = p.renderEditContent()
404+ contentHelp = help.New().View(p.keyMap)
375 case tools.WriteToolName:
405 case tools.WriteToolName:
376 contentFinal = p.renderWriteContent()
406 contentFinal = p.renderWriteContent()
377 case tools.FetchToolName:
407 case tools.FetchToolName:
@@ -381,8 +411,7 @@ func (p *permissionDialogCmp) render() string {
381 }
411 }
382 // Calculate content height dynamically based on window size
412 // Calculate content height dynamically based on window size
383
413
384− content := lipgloss.JoinVertical(
414+ strs := []string{
385− lipgloss.Top,
386 title,
415 title,
387 "",
416 "",
388 headerContent,
417 headerContent,
@@ -390,7 +419,11 @@ func (p *permissionDialogCmp) render() string {
390 "",
419 "",
391 buttons,
420 buttons,
392 "",
421 "",
393− )
422+ }
423+ if contentHelp != "" {
424+ strs = append(strs, "", contentHelp)
425+ }
426+ content := lipgloss.JoinVertical(lipgloss.Top, strs...)
394
427
395 return baseStyle.
428 return baseStyle.
396 Padding(0, 1).
429 Padding(0, 1).
internal/tui/keys.go+2−2
@@ -61,8 +61,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
61 }
61 }
62 }
62 }
63
63
64− for i := 0; i < len(cleaned); i += 2 {
64+ for i := 0; i < len(cleaned); i += 3 {
65− end := min(i+2, len(cleaned))
65+ end := min(i+3, len(cleaned))
66 m = append(m, cleaned[i:end])
66 m = append(m, cleaned[i:end])
67 }
67 }
68 return m
68 return m
internal/tui/page/chat/chat.go+32−5
@@ -3,6 +3,7 @@ package chat
3import (
3import (
4 "context"
4 "context"
5 "strings"
5 "strings"
6+ "time"
6
7
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,6 +34,7 @@ type (
33 ChatFocusedMsg struct {
34 ChatFocusedMsg struct {
34 Focused bool // True if the chat input is focused, false otherwise
35 Focused bool // True if the chat input is focused, false otherwise
35 }
36 }
37+ CancelTimerExpiredMsg struct{}
36)
38)
37
39
38type ChatPage interface {
40type ChatPage interface {
@@ -57,6 +59,8 @@ type chatPage struct {
57 showDetails bool // Show details in the header
59 showDetails bool // Show details in the header
58 header header.Header
60 header header.Header
59 compactSidebar layout.Container
61 compactSidebar layout.Container
62+
63+ cancelPending bool // True if ESC was pressed once and waiting for second press
60}
64}
61
65
62func (p *chatPage) Init() tea.Cmd {
66func (p *chatPage) Init() tea.Cmd {
@@ -67,9 +71,19 @@ func (p *chatPage) Init() tea.Cmd {
67 )
71 )
68}
72}
69
73
74+// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
75+func (p *chatPage) cancelTimerCmd() tea.Cmd {
76+ return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
77+ return CancelTimerExpiredMsg{}
78+ })
79+}
80+
70func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71 var cmds []tea.Cmd
82 var cmds []tea.Cmd
72 switch msg := msg.(type) {
83 switch msg := msg.(type) {
84+ case CancelTimerExpiredMsg:
85+ p.cancelPending = false
86+ return p, nil
73 case tea.WindowSizeMsg:
87 case tea.WindowSizeMsg:
74 h, cmd := p.header.Update(msg)
88 h, cmd := p.header.Update(msg)
75 cmds = append(cmds, cmd)
89 cmds = append(cmds, cmd)
@@ -181,10 +195,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181 return p, tea.Batch(cmds...)
195 return p, tea.Batch(cmds...)
182 case key.Matches(msg, p.keyMap.Cancel):
196 case key.Matches(msg, p.keyMap.Cancel):
183 if p.session.ID != "" {
197 if p.session.ID != "" {
184− // Cancel the current session's generation process
198+ if p.cancelPending {
185− // This allows users to interrupt long-running operations
199+ // Second ESC press - actually cancel the session
200+ p.cancelPending = false
186− p.app.CoderAgent.Cancel(p.session.ID)
201+ p.app.CoderAgent.Cancel(p.session.ID)
187− return p, nil
202+ return p, nil
203+ } else {
204+ // First ESC press - start the timer
205+ p.cancelPending = true
206+ return p, p.cancelTimerCmd()
207+ }
188 }
208 }
189 case key.Matches(msg, p.keyMap.Details):
209 case key.Matches(msg, p.keyMap.Details):
190 if p.session.ID == "" || !p.compactMode {
210 if p.session.ID == "" || !p.compactMode {
@@ -336,7 +356,14 @@ func (p *chatPage) Bindings() []key.Binding {
336 p.keyMap.AddAttachment,
356 p.keyMap.AddAttachment,
337 }
357 }
338 if p.app.CoderAgent.IsBusy() {
358 if p.app.CoderAgent.IsBusy() {
359+ cancelBinding := p.keyMap.Cancel
360+ if p.cancelPending {
361+ cancelBinding = key.NewBinding(
362+ key.WithKeys("esc"),
363+ key.WithHelp("esc", "press again to cancel"),
364+ )
365+ }
339− bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
366+ bindings = append([]key.Binding{cancelBinding}, bindings...)
340 }
367 }
341
368
342 if p.chatFocused {
369 if p.chatFocused {
internal/tui/styles/crush.go+8−4
@@ -14,9 +14,10 @@ func NewCrushTheme() *Theme {
14 Tertiary: charmtone.Bok,
14 Tertiary: charmtone.Bok,
15 Accent: charmtone.Zest,
15 Accent: charmtone.Zest,
16 // Backgrounds
16 // Backgrounds
17− BgBase: charmtone.Pepper,
17+ BgBase: charmtone.Pepper,
18+ BgBaseLighter: Lighten(charmtone.Pepper, 2),
18− BgSubtle: charmtone.Charcoal,
19+ BgSubtle: charmtone.Charcoal,
19− BgOverlay: charmtone.Iron,
20+ BgOverlay: charmtone.Iron,
20
21
21 // Foregrounds
22 // Foregrounds
22 FgBase: charmtone.Ash,
23 FgBase: charmtone.Ash,
@@ -38,7 +39,10 @@ func NewCrushTheme() *Theme {
38 // Colors
39 // Colors
39 White: charmtone.Butter,
40 White: charmtone.Butter,
40
41
42+ BlueLight: charmtone.Sardine,
41− Blue: charmtone.Malibu,
43+ Blue: charmtone.Malibu,
44+
45+ Yellow: charmtone.Mustard,
42
46
43 Green: charmtone.Julep,
47 Green: charmtone.Julep,
44 GreenDark: charmtone.Guac,
48 GreenDark: charmtone.Guac,
internal/tui/styles/theme.go+55−37
@@ -10,6 +10,7 @@ import (
10 "github.com/charmbracelet/bubbles/v2/textarea"
10 "github.com/charmbracelet/bubbles/v2/textarea"
11 "github.com/charmbracelet/bubbles/v2/textinput"
11 "github.com/charmbracelet/bubbles/v2/textinput"
12 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/exp/diffview"
13 "github.com/charmbracelet/glamour/v2/ansi"
14 "github.com/charmbracelet/glamour/v2/ansi"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/lucasb-eyer/go-colorful"
16 "github.com/lucasb-eyer/go-colorful"
@@ -31,9 +32,10 @@ type Theme struct {
31 Tertiary color.Color
32 Tertiary color.Color
32 Accent color.Color
33 Accent color.Color
33
34
35+ BgBase color.Color
34− BgBase color.Color
36+ BgBaseLighter color.Color
35− BgSubtle color.Color
37+ BgSubtle color.Color
36− BgOverlay color.Color
38+ BgOverlay color.Color
37
39
38 FgBase color.Color
40 FgBase color.Color
39 FgMuted color.Color
41 FgMuted color.Color
@@ -52,8 +54,13 @@ type Theme struct {
52 // Colors
54 // Colors
53 // White
55 // White
54 White color.Color
56 White color.Color
57+
55 // Blues
58 // Blues
59+ BlueLight color.Color
60+ Blue color.Color
61+
62+ // Yellows
56− Blue color.Color
63+ Yellow color.Color
57
64
58 // Greens
65 // Greens
59 Green color.Color
66 Green color.Color
@@ -65,26 +72,9 @@ type Theme struct {
65 RedDark color.Color
72 RedDark color.Color
66 RedLight color.Color
73 RedLight color.Color
67
74
68− // TODO: add any others needed
69−
70 styles *Styles
75 styles *Styles
71}
76}
72
77
73−type Diff struct {
74− Added color.Color
75− Removed color.Color
76− Context color.Color
77− HunkHeader color.Color
78− HighlightAdded color.Color
79− HighlightRemoved color.Color
80− AddedBg color.Color
81− RemovedBg color.Color
82− ContextBg color.Color
83− LineNumber color.Color
84− AddedLineNumberBg color.Color
85− RemovedLineNumberBg color.Color
86−}
87−
88type Styles struct {
78type Styles struct {
89 Base lipgloss.Style
79 Base lipgloss.Style
90 SelectedBase lipgloss.Style
80 SelectedBase lipgloss.Style
@@ -112,7 +102,7 @@ type Styles struct {
112 Help help.Styles
102 Help help.Styles
113
103
114 // Diff
104 // Diff
115− Diff Diff
105+ Diff diffview.Style
116
106
117 // FilePicker
107 // FilePicker
118 FilePicker filepicker.Styles
108 FilePicker filepicker.Styles
@@ -421,22 +411,50 @@ func (t *Theme) buildStyles() *Styles {
421 FullSeparator: base.Foreground(t.Border),
411 FullSeparator: base.Foreground(t.Border),
422 },
412 },
423
413
424− // TODO: Fix this this is bad
414+ Diff: diffview.Style{
425− Diff: Diff{
415+ DividerLine: diffview.LineStyle{
426− Added: t.Green,
416+ LineNumber: lipgloss.NewStyle().
427− Removed: t.Red,
417+ Foreground(t.FgHalfMuted).
428− Context: t.FgSubtle,
418+ Background(t.BgBaseLighter),
429− HunkHeader: t.FgSubtle,
419+ Code: lipgloss.NewStyle().
430− HighlightAdded: t.GreenLight,
420+ Foreground(t.FgHalfMuted).
431− HighlightRemoved: t.RedLight,
421+ Background(t.BgBaseLighter),
432− AddedBg: t.GreenDark,
422+ },
433− RemovedBg: t.RedDark,
423+ MissingLine: diffview.LineStyle{
434− ContextBg: t.BgSubtle,
424+ LineNumber: lipgloss.NewStyle().
425+ Background(t.BgBaseLighter),
426+ Code: lipgloss.NewStyle().
427+ Background(t.BgBaseLighter),
428+ },
429+ EqualLine: diffview.LineStyle{
430+ LineNumber: lipgloss.NewStyle().
431+ Foreground(t.FgMuted).
432+ Background(t.BgBase),
433+ Code: lipgloss.NewStyle().
434+ Foreground(t.FgMuted).
435+ Background(t.BgBase),
436+ },
437+ InsertLine: diffview.LineStyle{
438+ LineNumber: lipgloss.NewStyle().
439+ Foreground(lipgloss.Color("#629657")).
440+ Background(lipgloss.Color("#2b322a")),
441+ Symbol: lipgloss.NewStyle().
442+ Foreground(lipgloss.Color("#629657")).
443+ Background(lipgloss.Color("#323931")),
444+ Code: lipgloss.NewStyle().
445+ Background(lipgloss.Color("#323931")),
446+ },
447+ DeleteLine: diffview.LineStyle{
435− LineNumber: t.FgMuted,
448+ LineNumber: lipgloss.NewStyle().
436− AddedLineNumberBg: t.GreenDark,
449+ Foreground(lipgloss.Color("#a45c59")).
437− RemovedLineNumberBg: t.RedDark,
450+ Background(lipgloss.Color("#312929")),
451+ Symbol: lipgloss.NewStyle().
452+ Foreground(lipgloss.Color("#a45c59")).
453+ Background(lipgloss.Color("#383030")),
454+ Code: lipgloss.NewStyle().
455+ Background(lipgloss.Color("#383030")),
456+ },
438 },
457 },
439−
440 FilePicker: filepicker.Styles{
458 FilePicker: filepicker.Styles{
441 DisabledCursor: base.Foreground(t.FgMuted),
459 DisabledCursor: base.Foreground(t.FgMuted),
442 Cursor: base.Foreground(t.FgBase),
460 Cursor: base.Foreground(t.FgBase),
internal/tui/tui.go+1−7
@@ -94,12 +94,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94
94
95 switch msg := msg.(type) {
95 switch msg := msg.(type) {
96 case tea.KeyboardEnhancementsMsg:
96 case tea.KeyboardEnhancementsMsg:
97− logging.Info(
98− "Keyboard enhancements detected",
99− "Disambiguation", msg.SupportsKeyDisambiguation(),
100− "ReleaseKeys", msg.SupportsKeyReleases(),
101− "UniformKeys", msg.SupportsUniformKeyLayout(),
102− )
103 return a, nil
97 return a, nil
104 case tea.WindowSizeMsg:
98 case tea.WindowSizeMsg:
105 return a, a.handleWindowResize(msg.Width, msg.Height)
99 return a, a.handleWindowResize(msg.Width, msg.Height)
@@ -260,7 +254,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
260 var cmds []tea.Cmd
254 var cmds []tea.Cmd
261 a.wWidth, a.wHeight = width, height
255 a.wWidth, a.wHeight = width, height
262 if a.showingFullHelp {
256 if a.showingFullHelp {
263− height -= 3
257+ height -= 4
264 } else {
258 } else {
265 height -= 2
259 height -= 2
266 }
260 }
todos.md+9
@@ -20,7 +20,16 @@
20- [ ] Parallel tool calls and permissions
20- [ ] Parallel tool calls and permissions
21 - [ ] Run the tools in parallel and add results in parallel
21 - [ ] Run the tools in parallel and add results in parallel
22 - [ ] Show multiple permissions dialogs
22 - [ ] Show multiple permissions dialogs
23+- [ ] Add another space around buttons
24+- [ ] Completions
25+ - [ ] Should change the help to show the completions stuff
26+ - [ ] Should make it wider
27+ - [ ] Tab and ctrl+y should accept
28+ - [ ] Words should line up
29+ - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
23- [ ] Investigate messages issues
30- [ ] Investigate messages issues
31+ - [ ] Make the agent separator look like the
32+ - [ ] Cleanup tool calls (watch all states)
24 - [ ] Weird behavior sometimes the message does not update
33 - [ ] Weird behavior sometimes the message does not update
25 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
34 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
26 - [ ] Address UX issues
35 - [ ] Address UX issues