on main help sign in
Merge remote-tracking branch 'origin/main' into ref
commitd475c2236c7bc0611798642f502942e03156e643 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 20 Jun 2025, 11:27 (1 year ago) parent8146006f9890c2
92 files changed+997−494
cspell.json+1−1 internal/app/app.go+1 internal/exp/diffview/Taskfile.yaml+20 internal/exp/diffview/diffview.go+235−177 internal/exp/diffview/diffview_test.go+27 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden+1−1 internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden+1−1 internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden+2−2 internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden+3−3 internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden+4−4 internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden+5−5 internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden+5−5 internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden+5−5 internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden+2−2 internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden+3−3 internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden+4−4 internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden+5−5 internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden+5−5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden+5 internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden+5 internal/llm/agent/agent.go+8 internal/llm/prompt/coder.go+6−6 internal/llm/tools/bash.go+2−1 internal/tui/components/chat/chat.go+13−4 internal/tui/components/chat/messages/messages.go+64−44 internal/tui/components/chat/messages/renderer.go+144−91 internal/tui/components/chat/messages/tool.go+15−11 internal/tui/components/chat/sidebar/sidebar.go+3−3 internal/tui/components/core/helpers.go+2−1 internal/tui/components/core/list/keys.go+3−3 internal/tui/components/core/list/list.go+4−2 internal/tui/components/core/status/status.go+27−9 internal/tui/components/dialogs/commands/arguments.go−9 internal/tui/components/dialogs/models/models.go+4−4 internal/tui/components/dialogs/permissions/keys.go+41−6 internal/tui/components/dialogs/permissions/permissions.go+37−4 internal/tui/keys.go+2−2 internal/tui/page/chat/chat.go+32−5 internal/tui/styles/crush.go+8−4 internal/tui/styles/theme.go+55−37 internal/tui/tui.go+1−7 todos.md+9
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() {
163163 }
164164 cancel()
165165 }
166+ app.CoderAgent.CancelAll()
166167}
internal/exp/diffview/Taskfile.yaml+20
@@ -100,3 +100,23 @@ tasks:
100100 - for: sources
101101 cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
102102 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 (
3333
3434// DiffView represents a view for displaying differences between two files.
3535type DiffView struct {
36− layout layout
37− before file
38− after file
39− contextLines int
40− lineNumbers bool
41− height int
42− width int
43− xOffset int
44− yOffset int
45− style Style
46− tabWidth int
47− chromaStyle *chroma.Style
36+ layout layout
37+ before file
38+ after file
39+ contextLines int
40+ lineNumbers bool
41+ height int
42+ width int
43+ xOffset int
44+ yOffset int
45+ infiniteYScroll bool
46+ style Style
47+ tabWidth int
48+ chromaStyle *chroma.Style
4849
4950 isComputed bool
5051 err error
@@ -53,6 +54,7 @@ type DiffView struct {
5354
5455 splitHunks []splitHunk
5556
57+ totalLines int
5658 codeWidth int
5759 fullCodeWidth int // with leading symbols
5860 extraColOnAfter bool // add extra column on after panel
@@ -138,6 +140,12 @@ func (dv *DiffView) YOffset(yOffset int) *DiffView {
138140 return dv
139141}
140142
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+
141149// TabWidth sets the tab width. Only relevant for code that contains tabs, like
142150// Go code.
143151func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
@@ -161,6 +169,8 @@ func (dv *DiffView) String() string {
161169 dv.convertDiffToSplit()
162170 dv.adjustStyles()
163171 dv.detectNumDigits()
172+ dv.detectTotalLines()
173+ dv.preventInfiniteYScroll()
164174
165175 if dv.width <= 0 {
166176 dv.detectCodeWidth()
@@ -251,6 +261,37 @@ func (dv *DiffView) detectNumDigits() {
251261 }
252262}
253263
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+
254295// detectCodeWidth calculates the maximum width of code lines in the diff view.
255296func (dv *DiffView) detectCodeWidth() {
256297 switch dv.layout {
@@ -321,23 +362,29 @@ func (dv *DiffView) renderUnified() string {
321362
322363 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
323364 printedLines := -dv.yOffset
324−
325− write := func(s string) {
326− if printedLines >= 0 {
327− b.WriteString(s)
328− }
365+ shouldWrite := func() bool { return printedLines >= 0 }
366+
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
329374 }
330375
331376outer:
332377 for i, h := range dv.unified.Hunks {
333− ls := dv.style.DividerLine
334− if dv.lineNumbers {
335− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
336− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
378+ if shouldWrite() {
379+ ls := dv.style.DividerLine
380+ if dv.lineNumbers {
381+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
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")
337387 }
338− content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
339− write(ls.Code.Width(dv.fullCodeWidth).Render(content))
340− write("\n")
341388 printedLines++
342389
343390 beforeLine := h.FromLine
@@ -349,80 +396,82 @@ outer:
349396 isLastHunk := i+1 == len(dv.unified.Hunks)
350397 isLastLine := j+1 == len(h.Lines)
351398 if hasReachedHeight && (!isLastHunk || !isLastLine) {
352− ls := dv.lineStyleForType(l.Kind)
353− if dv.lineNumbers {
354− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
355− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
399+ if shouldWrite() {
400+ ls := dv.lineStyleForType(l.Kind)
401+ if dv.lineNumbers {
402+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
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')
356409 }
357− write(fullContentStyle.Render(
358− ls.Code.Width(dv.fullCodeWidth).Render(" …"),
359− ))
360− write("\n")
361410 break outer
362411 }
363412
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−
374413 switch l.Kind {
375414 case udiff.Equal:
376− ls := dv.style.EqualLine
377− content := getContent(ls)
378− if dv.lineNumbers {
379− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
380− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
415+ if shouldWrite() {
416+ ls := dv.style.EqualLine
417+ content, leadingEllipsis := getContent(l.Content, ls)
418+ if dv.lineNumbers {
419+ b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
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+ ))
381425 }
382− write(fullContentStyle.Render(
383− ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
384− ))
385426 beforeLine++
386427 afterLine++
387428 case udiff.Insert:
388− ls := dv.style.InsertLine
389− content := getContent(ls)
390− if dv.lineNumbers {
391− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
392− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
429+ if shouldWrite() {
430+ ls := dv.style.InsertLine
431+ content, leadingEllipsis := getContent(l.Content, ls)
432+ if dv.lineNumbers {
433+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
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+ ))
393440 }
394− write(fullContentStyle.Render(
395− ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
396− ls.Code.Width(dv.codeWidth).Render(content),
397− ))
398441 afterLine++
399442 case udiff.Delete:
400− ls := dv.style.DeleteLine
401− content := getContent(ls)
402− if dv.lineNumbers {
403− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
404− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
443+ if shouldWrite() {
444+ ls := dv.style.DeleteLine
445+ content, leadingEllipsis := getContent(l.Content, ls)
446+ if dv.lineNumbers {
447+ b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
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+ ))
405454 }
406− write(fullContentStyle.Render(
407− ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
408− ls.Code.Width(dv.codeWidth).Render(content),
409− ))
410455 beforeLine++
411456 }
412− write("\n")
457+ if shouldWrite() {
458+ b.WriteRune('\n')
459+ }
413460
414461 printedLines++
415462 }
416463 }
417464
418465 for printedLines < dv.height {
419− ls := dv.style.MissingLine
420− if dv.lineNumbers {
421− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
422− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
466+ if shouldWrite() {
467+ ls := dv.style.MissingLine
468+ if dv.lineNumbers {
469+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
470+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
471+ }
472+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
473+ b.WriteRune('\n')
423474 }
424− write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
425− write("\n")
426475 printedLines++
427476 }
428477
@@ -436,26 +485,32 @@ func (dv *DiffView) renderSplit() string {
436485 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
437486 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
438487 printedLines := -dv.yOffset
439−
440− write := func(s string) {
441− if printedLines >= 0 {
442− b.WriteString(s)
443− }
488+ shouldWrite := func() bool { return printedLines >= 0 }
489+
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
444497 }
445498
446499outer:
447500 for i, h := range dv.splitHunks {
448− ls := dv.style.DividerLine
449− if dv.lineNumbers {
450− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
451− }
452− content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
453− write(ls.Code.Width(dv.fullCodeWidth).Render(content))
454− if dv.lineNumbers {
455− write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
501+ if shouldWrite() {
502+ ls := dv.style.DividerLine
503+ if dv.lineNumbers {
504+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
505+ }
506+ content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
507+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
508+ if dv.lineNumbers {
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')
456513 }
457− write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
458− write("\n")
459514 printedLines++
460515
461516 beforeLine := h.fromLine
@@ -467,126 +522,129 @@ outer:
467522 isLastHunk := i+1 == len(dv.unified.Hunks)
468523 isLastLine := j+1 == len(h.lines)
469524 if hasReachedHeight && (!isLastHunk || !isLastLine) {
470− ls := dv.style.MissingLine
471− if l.before != nil {
472− ls = dv.lineStyleForType(l.before.Kind)
473− }
474− if dv.lineNumbers {
475− write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
476− }
477− write(beforeFullContentStyle.Render(
478− ls.Code.Width(dv.fullCodeWidth).Render(" …"),
479− ))
480− ls = dv.style.MissingLine
481− if l.after != nil {
482− ls = dv.lineStyleForType(l.after.Kind)
525+ if shouldWrite() {
526+ ls := dv.style.MissingLine
527+ if l.before != nil {
528+ ls = dv.lineStyleForType(l.before.Kind)
529+ }
530+ if dv.lineNumbers {
531+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
532+ }
533+ b.WriteString(beforeFullContentStyle.Render(
534+ ls.Code.Width(dv.fullCodeWidth).Render(" …"),
535+ ))
536+ ls = dv.style.MissingLine
537+ if l.after != nil {
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')
483547 }
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")
491548 break outer
492549 }
493550
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−
505551 switch {
506552 case l.before == nil:
507− ls := dv.style.MissingLine
508− if dv.lineNumbers {
509− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
553+ if shouldWrite() {
554+ ls := dv.style.MissingLine
555+ if dv.lineNumbers {
556+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
557+ }
558+ b.WriteString(beforeFullContentStyle.Render(
559+ ls.Code.Width(dv.fullCodeWidth).Render(" "),
560+ ))
510561 }
511− write(beforeFullContentStyle.Render(
512− ls.Code.Width(dv.fullCodeWidth).Render(" "),
513− ))
514562 case l.before.Kind == udiff.Equal:
515− ls := dv.style.EqualLine
516− content := getContent(l.before.Content, ls)
517− leadingEllipsis := getLeadingEllipsis(content)
518− if dv.lineNumbers {
519− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
563+ if shouldWrite() {
564+ ls := dv.style.EqualLine
565+ content, leadingEllipsis := getContent(l.before.Content, ls)
566+ if dv.lineNumbers {
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+ ))
520572 }
521− write(beforeFullContentStyle.Render(
522− ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
523− ))
524573 beforeLine++
525574 case l.before.Kind == udiff.Delete:
526− ls := dv.style.DeleteLine
527− content := getContent(l.before.Content, ls)
528− leadingEllipsis := getLeadingEllipsis(content)
529− if dv.lineNumbers {
530− write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
575+ if shouldWrite() {
576+ ls := dv.style.DeleteLine
577+ content, leadingEllipsis := getContent(l.before.Content, ls)
578+ if dv.lineNumbers {
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+ ))
531585 }
532− write(beforeFullContentStyle.Render(
533− ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
534− ls.Code.Width(dv.codeWidth).Render(content),
535− ))
536586 beforeLine++
537587 }
538588
539589 switch {
540590 case l.after == nil:
541− ls := dv.style.MissingLine
542− if dv.lineNumbers {
543− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
591+ if shouldWrite() {
592+ ls := dv.style.MissingLine
593+ if dv.lineNumbers {
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+ ))
544599 }
545− write(afterFullContentStyle.Render(
546− ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
547− ))
548600 case l.after.Kind == udiff.Equal:
549− ls := dv.style.EqualLine
550− content := getContent(l.after.Content, ls)
551− leadingEllipsis := getLeadingEllipsis(content)
552− if dv.lineNumbers {
553− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
601+ if shouldWrite() {
602+ ls := dv.style.EqualLine
603+ content, leadingEllipsis := getContent(l.after.Content, ls)
604+ if dv.lineNumbers {
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+ ))
554610 }
555− write(afterFullContentStyle.Render(
556− ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
557− ))
558611 afterLine++
559612 case l.after.Kind == udiff.Insert:
560− ls := dv.style.InsertLine
561− content := getContent(l.after.Content, ls)
562− leadingEllipsis := getLeadingEllipsis(content)
563− if dv.lineNumbers {
564− write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
613+ if shouldWrite() {
614+ ls := dv.style.InsertLine
615+ content, leadingEllipsis := getContent(l.after.Content, ls)
616+ if dv.lineNumbers {
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+ ))
565623 }
566− write(afterFullContentStyle.Render(
567− ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
568− ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
569− ))
570624 afterLine++
571625 }
572626
573− write("\n")
627+ if shouldWrite() {
628+ b.WriteRune('\n')
629+ }
574630
575631 printedLines++
576632 }
577633 }
578634
579635 for printedLines < dv.height {
580− ls := dv.style.MissingLine
581− if dv.lineNumbers {
582− write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
583− }
584− write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
585− if dv.lineNumbers {
586− write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
636+ if shouldWrite() {
637+ ls := dv.style.MissingLine
638+ if dv.lineNumbers {
639+ b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
640+ }
641+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
642+ if dv.lineNumbers {
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')
587647 }
588− write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
589− write("\n")
590648 printedLines++
591649 }
592650
internal/exp/diffview/diffview_test.go+27
@@ -283,6 +283,33 @@ func TestDiffViewYOffset(t *testing.T) {
283283 }
284284}
285285
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+
286313func assertLineWidth(t *testing.T, expected int, output string) {
287314 var lineWidth int
288315 for line := range strings.SplitSeq(output, "\n") {
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …unc main() { 
55  8    -… fmt.Println("Hello, world!") 
66     8 +… content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …nc main() { 
55  8    -… fmt.Println("Hello, world!") 
66     8 +… content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …c main() { 
55  8    -… fmt.Println("Hello, world!") 
66     8 +… content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … main() { 
55  8    -…fmt.Println("Hello, world!") 
66     8 +…content := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …main() { 
55  8    -…mt.Println("Hello, world!") 
66     8 +…ontent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …ain() { 
55  8    -…t.Println("Hello, world!") 
66     8 +…ntent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …in() { 
55  8    -….Println("Hello, world!") 
66     8 +…tent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …n() { 
55  8    -…Println("Hello, world!") 
66     8 +…ent := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …() { 
55  8    -…rintln("Hello, world!") 
66     8 +…nt := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …) { 
55  8    -…intln("Hello, world!") 
66     8 +…t := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … { 
55  8    -…ntln("Hello, world!") 
66     8 +… := "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  …{ 
55  8    -…tln("Hello, world!") 
66     8 +…:= "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…ln("Hello, world!") 
66     8 +…= "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…n("Hello, world!") 
66     8 +… "Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…("Hello, world!") 
66     8 +…"Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…"Hello, world!") 
66     8 +…Hello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…Hello, world!") 
66     8 +…ello, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…ello, world!") 
66     8 +…llo, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…llo, world!") 
66     8 +…lo, world!" 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden+1−1
@@ -1,6 +1,6 @@
11  …   …  @@ -5,5 +5,6 @@  
22  5   5  … 
3−  6   6  … 
3+  6   6   
44  7   7  … 
55  8    -…lo, world!") 
66     8 +…o, world!" 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden+2−2
@@ -1,5 +1,5 @@
1+ 10    11   
12 11  func getContent() string {  12  func getContent() string { 
23 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
34      14 +  return content 
4− 13  }  15  } 
5−          
5+ 13  }  15  } 
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 { 
13 12 -  return "Hello, world!"  13 +  content := strings.ToUpper("Hello, World!") 
24      14 +  return content 
3− 13  }  15  } 
4−          
5−          
5+ 13  }  15  } 
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!") 
14      14 +  return content 
2− 13  }  15  } 
3−          
4−          
5−          
5+ 13  }  15  } 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden+5−5
@@ -1,5 +1,5 @@
1− 13  }  15  } 
2−          
3−          
4−          
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/TestDiffViewYOffset/Split/YOffsetOf15.golden+5−5
@@ -1,5 +1,5 @@
1−          
2−          
3−          
4−          
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/TestDiffViewYOffset/Split/YOffsetOf16.golden+5−5
@@ -1,5 +1,5 @@
1−          
2−          
3−          
4−          
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/TestDiffViewYOffset/Unified/YOffsetOf12.golden+2−2
@@ -1,5 +1,5 @@
1+ 11  12  func getContent() string { 
12 12    -  return "Hello, world!" 
23    13 +  content := strings.ToUpper("Hello, World!") 
34    14 +  return content 
4− 13  15  } 
5−        
5+ 13  15  } 
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!" 
13    13 +  content := strings.ToUpper("Hello, World!") 
24    14 +  return content 
3− 13  15  } 
4−        
5−        
5+ 13  15  } 
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!") 
14    14 +  return content 
2− 13  15  } 
3−        
4−        
5−        
5+ 13  15  } 
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden+5−5
@@ -1,5 +1,5 @@
1− 13  15  } 
2−        
3−        
4−        
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/TestDiffViewYOffset/Unified/YOffsetOf16.golden+5−5
@@ -1,5 +1,5 @@
1−        
2−        
3−        
4−        
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/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 {
5050 Model() models.Model
5151 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
5252 Cancel(sessionID string)
53+ CancelAll()
5354 IsSessionBusy(sessionID string) bool
5455 IsBusy() bool
5556 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
@@ -698,6 +699,13 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
698699 return nil
699700}
700701
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+
701709func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
702710 cfg := config.Get()
703711 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
3030You can:
3131- Receive user prompts, project context, and files.
3232- 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"
3733
3834
3935You 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:
6460- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
6561 - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
6662- 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.
6864 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
6965- 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.
7066- If you send a path not including the working dir, the working dir will be prepended to it.
7167- Remember the user does not see the full output of tools
68+- NEVER use emojis in your responses
7269`
7370
7471const 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.
7572
7673IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
7774
75+
7876# Memory
7977If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
80781. 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
131129
132130<example>
133131user: 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]
135133</example>
136134
137135# Proactiveness
@@ -165,6 +163,8 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
165163- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
166164- 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.
167165
166+VERY IMPORTANT NEVER use emojis in your responses.
167+
168168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
169169
170170func getEnvironmentInfo() string {
internal/llm/tools/bash.go+2−1
@@ -36,6 +36,7 @@ const (
3636 DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
3737 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
3838 MaxOutputLength = 30000
39+ BashNoOutput = "no output"
3940)
4041
4142var bannedCommands = []string{
@@ -321,7 +322,7 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
321322 EndTime: time.Now().UnixMilli(),
322323 }
323324 if stdout == "" {
324− return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
325+ return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
325326 }
326327 return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
327328}
internal/tui/components/chat/chat.go+13−4
@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
272272 assistantIndex = i
273273 }
274274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
275− if tc.ParentMessageId() == messageID {
275+ if tc.ParentMessageID() == messageID {
276276 toolCalls[i] = tc
277277 }
278278 }
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
295295 assistantIndex,
296296 messages.NewMessageCmp(
297297 msg,
298− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
299298 ),
300299 )
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+ }
301309 } else if hasToolCallsOnly {
302310 m.listCmp.DeleteItem(assistantIndex)
303311 }
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
347355 cmd := m.listCmp.AppendItem(
348356 messages.NewMessageCmp(
349357 msg,
350− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
351358 ),
352359 )
353360 cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
412419 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
413420 case message.Assistant:
414421 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+ }
415425 }
416426 }
417427
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
428438 uiMessages,
429439 messages.NewMessageCmp(
430440 msg,
431− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
432441 ),
433442 )
434443 }
internal/tui/components/chat/messages/messages.go+64−44
@@ -8,13 +8,14 @@ import (
88
99 "github.com/charmbracelet/bubbles/v2/spinner"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/llm/models"
1211 "github.com/charmbracelet/lipgloss/v2"
1312
13+ "github.com/charmbracelet/crush/internal/llm/models"
1414 "github.com/charmbracelet/crush/internal/message"
1515 "github.com/charmbracelet/crush/internal/tui/components/anim"
1616 "github.com/charmbracelet/crush/internal/tui/components/core"
1717 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
1819 "github.com/charmbracelet/crush/internal/tui/styles"
1920 "github.com/charmbracelet/crush/internal/tui/util"
2021)
@@ -37,32 +38,17 @@ type messageCmp struct {
3738 focused bool // Focus state for border styling
3839
3940 // Core message data and state
40− message message.Message // The underlying message content
41− spinning bool // Whether to show loading animation
42− 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− }
41+ message message.Message // The underlying message content
42+ spinning bool // Whether to show loading animation
43+ anim util.Model // Animation component for loading states
5544}
5645
5746// 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 {
5948 m := &messageCmp{
6049 message: msg,
6150 anim: anim.New(15, ""),
6251 }
63− for _, opt := range opts {
64− opt(m)
65− }
6652 return m
6753}
6854
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
145131// renderAssistantMessage renders assistant messages with optional footer information.
146132// Shows model name, response time, and finish reason when the message is complete.
147133func (m *messageCmp) renderAssistantMessage() string {
148− t := styles.CurrentTheme()
149134 parts := []string{
150135 m.markdownContent(),
151136 }
152137
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−
174138 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175139 return m.style().Render(joined)
176140}
@@ -200,7 +164,7 @@ func (m *messageCmp) renderUserMessage() string {
200164 parts = append(parts, "", strings.Join(attachments, ""))
201165 }
202166 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
203− return m.style().MarginBottom(1).Render(joined)
167+ return m.style().Render(joined)
204168}
205169
206170// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
225189 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226190 // Sometimes the LLMs respond with no content when they think the previous tool result
227191 // provides the requested question
228− content = "*Finished without output*"
192+ content = ""
229193 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230194 content = "*Canceled*"
231195 }
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
287251func (m *messageCmp) Spinning() bool {
288252 return m.spinning
289253}
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 {
111111 return json.Unmarshal([]byte(input), target)
112112}
113113
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+
114121// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
115122func (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+ }
116126 t := styles.CurrentTheme()
117127 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
118128 if v.result.ToolCallID != "" {
@@ -125,16 +135,17 @@ func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params
125135 icon = t.S().Muted.Render(styles.ToolPending)
126136 }
127137 tool = t.S().Base.Foreground(t.Blue).Render(tool)
128− prefix := fmt.Sprintf("%s %s: ", icon, tool)
129− return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
138+ prefix := fmt.Sprintf("%s %s ", icon, tool)
139+ return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
130140}
131141
132142// renderError provides consistent error rendering
133143func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
134144 t := styles.CurrentTheme()
135145 header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
136− message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
137− return joinHeaderBody(header, message)
146+ errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
147+ message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
148+ return joinHeaderBody(header, errorTag+" "+message)
138149}
139150
140151// Register tool renderers
@@ -188,6 +199,9 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
188199 args := newParamBuilder().addMain(cmd).build()
189200
190201 return br.renderWithParams(v, "Bash", args, func() string {
202+ if v.result.Content == tools.BashNoOutput {
203+ return ""
204+ }
191205 return renderPlainContent(v, v.result.Content)
192206 })
193207}
@@ -244,13 +258,12 @@ type editRenderer struct {
244258// Render displays the edited file with a formatted diff of changes
245259func (er editRenderer) Render(v *toolCallCmp) string {
246260 var params tools.EditParams
247− if err := er.unmarshalParams(v.call.Input, &params); err != nil {
248− return er.renderError(v, "Invalid edit parameters")
261+ var args []string
262+ if err := er.unmarshalParams(v.call.Input, &params); err == nil {
263+ file := fsext.PrettyPath(params.FilePath)
264+ args = newParamBuilder().addMain(file).build()
249265 }
250266
251− file := fsext.PrettyPath(params.FilePath)
252− args := newParamBuilder().addMain(file).build()
253−
254267 return er.renderWithParams(v, "Edit", args, func() string {
255268 var meta tools.EditResponseMetadata
256269 if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
@@ -260,8 +273,10 @@ func (er editRenderer) Render(v *toolCallCmp) string {
260273 formatter := core.DiffFormatter().
261274 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
262275 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
263− Split().
264276 Width(v.textWidth() - 2) // -2 for padding
277+ if v.textWidth() > 120 {
278+ formatter = formatter.Split()
279+ }
265280 return formatter.String()
266281 })
267282}
@@ -278,13 +293,13 @@ type writeRenderer struct {
278293// Render displays the file being written with syntax highlighting
279294func (wr writeRenderer) Render(v *toolCallCmp) string {
280295 var params tools.WriteParams
281− if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
282− return wr.renderError(v, "Invalid write parameters")
296+ var args []string
297+ var file string
298+ if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
299+ file = fsext.PrettyPath(params.FilePath)
300+ args = newParamBuilder().addMain(file).build()
283301 }
284302
285− file := fsext.PrettyPath(params.FilePath)
286− args := newParamBuilder().addMain(file).build()
287−
288303 return wr.renderWithParams(v, "Write", args, func() string {
289304 return renderCodeContent(v, file, params.Content, 0)
290305 })
@@ -302,16 +317,15 @@ type fetchRenderer struct {
302317// Render displays the fetched URL with format and timeout parameters
303318func (fr fetchRenderer) Render(v *toolCallCmp) string {
304319 var params tools.FetchParams
305− if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
306− return fr.renderError(v, "Invalid fetch parameters")
320+ var args []string
321+ if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
322+ args = newParamBuilder().
323+ addMain(params.URL).
324+ addKeyValue("format", params.Format).
325+ addKeyValue("timeout", formatTimeout(params.Timeout)).
326+ build()
307327 }
308328
309− args := newParamBuilder().
310− addMain(params.URL).
311− addKeyValue("format", params.Format).
312− addKeyValue("timeout", formatTimeout(params.Timeout)).
313− build()
314−
315329 return fr.renderWithParams(v, "Fetch", args, func() string {
316330 file := fr.getFileExtension(params.Format)
317331 return renderCodeContent(v, file, v.result.Content, 0)
@@ -350,15 +364,14 @@ type globRenderer struct {
350364// Render displays the glob pattern with optional path parameter
351365func (gr globRenderer) Render(v *toolCallCmp) string {
352366 var params tools.GlobParams
353− if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
354− return gr.renderError(v, "Invalid glob parameters")
367+ var args []string
368+ if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
369+ args = newParamBuilder().
370+ addMain(params.Pattern).
371+ addKeyValue("path", params.Path).
372+ build()
355373 }
356374
357− args := newParamBuilder().
358− addMain(params.Pattern).
359− addKeyValue("path", params.Path).
360− build()
361−
362375 return gr.renderWithParams(v, "Glob", args, func() string {
363376 return renderPlainContent(v, v.result.Content)
364377 })
@@ -376,17 +389,16 @@ type grepRenderer struct {
376389// Render displays the search pattern with path, include, and literal text options
377390func (gr grepRenderer) Render(v *toolCallCmp) string {
378391 var params tools.GrepParams
379− if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
380− return gr.renderError(v, "Invalid grep parameters")
392+ var args []string
393+ if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
394+ args = newParamBuilder().
395+ addMain(params.Pattern).
396+ addKeyValue("path", params.Path).
397+ addKeyValue("include", params.Include).
398+ addFlag("literal", params.LiteralText).
399+ build()
381400 }
382401
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−
390402 return gr.renderWithParams(v, "Grep", args, func() string {
391403 return renderPlainContent(v, v.result.Content)
392404 })
@@ -404,17 +416,16 @@ type lsRenderer struct {
404416// Render displays the directory path, defaulting to current directory
405417func (lr lsRenderer) Render(v *toolCallCmp) string {
406418 var params tools.LSParams
407− if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
408− return lr.renderError(v, "Invalid ls parameters")
409− }
419+ var args []string
420+ if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
421+ path := params.Path
422+ if path == "" {
423+ path = "."
424+ }
425+ path = fsext.PrettyPath(path)
410426
411− path := params.Path
412− if path == "" {
413− path = "."
427+ args = newParamBuilder().addMain(path).build()
414428 }
415− path = fsext.PrettyPath(path)
416−
417− args := newParamBuilder().addMain(path).build()
418429
419430 return lr.renderWithParams(v, "List", args, func() string {
420431 return renderPlainContent(v, v.result.Content)
@@ -433,16 +444,15 @@ type sourcegraphRenderer struct {
433444// Render displays the search query with optional count and context window parameters
434445func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
435446 var params tools.SourcegraphParams
436− if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
437− return sr.renderError(v, "Invalid sourcegraph parameters")
447+ var args []string
448+ if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
449+ args = newParamBuilder().
450+ addMain(params.Query).
451+ addKeyValue("count", formatNonZero(params.Count)).
452+ addKeyValue("context", formatNonZero(params.ContextWindow)).
453+ build()
438454 }
439455
440− args := newParamBuilder().
441− addMain(params.Query).
442− addKeyValue("count", formatNonZero(params.Count)).
443− addKeyValue("context", formatNonZero(params.ContextWindow)).
444− build()
445−
446456 return sr.renderWithParams(v, "Sourcegraph", args, func() string {
447457 return renderPlainContent(v, v.result.Content)
448458 })
@@ -475,25 +485,47 @@ type agentRenderer struct {
475485 baseRenderer
476486}
477487
488+func RoundedEnumerator(children tree.Children, index int) string {
489+ if children.Length()-1 == index {
490+ return " ╰──"
491+ }
492+ return " ├──"
493+}
494+
478495// Render displays agent task parameters and result content
479496func (tr agentRenderer) Render(v *toolCallCmp) string {
497+ t := styles.CurrentTheme()
480498 var params agent.AgentParams
481− if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
482− return tr.renderError(v, "Invalid task parameters")
483− }
499+ tr.unmarshalParams(v.call.Input, &params)
500+
484501 prompt := params.Prompt
485502 prompt = strings.ReplaceAll(prompt, "\n", " ")
486− args := newParamBuilder().addMain(prompt).build()
487503
488− header := tr.makeHeader(v, "Task", v.textWidth(), args...)
489− t := tree.Root(header)
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+ )
522+ childTools := tree.Root(header)
490523
491524 for _, call := range v.nestedToolCalls {
492− t.Child(call.View())
525+ childTools.Child(call.View())
493526 }
494−
495527 parts := []string{
496− t.Enumerator(tree.RoundedEnumerator).String(),
528+ childTools.Enumerator(RoundedEnumerator).String(),
497529 }
498530 if v.result.ToolCallID == "" {
499531 v.spinning = true
@@ -516,7 +548,8 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
516548}
517549
518550// 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()
520553 if len(params) == 0 {
521554 return ""
522555 }
@@ -526,7 +559,10 @@ func renderParamList(paramsWidth int, params ...string) string {
526559 }
527560
528561 if len(params) == 1 {
529− return mainParam
562+ if nested {
563+ return t.S().Muted.Render(mainParam)
564+ }
565+ return t.S().Subtle.Render(mainParam)
530566 }
531567 otherParams := params[1:]
532568 // create pairs of key/value
@@ -547,15 +583,21 @@ func renderParamList(paramsWidth int, params ...string) string {
547583 partsRendered := strings.Join(parts, ", ")
548584 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
549585 if remainingWidth < 30 {
586+ if nested {
587+ return t.S().Muted.Render(mainParam)
588+ }
550589 // No space for the params, just show the main
551− return mainParam
590+ return t.S().Subtle.Render(mainParam)
552591 }
553592
554593 if len(parts) > 0 {
555594 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
556595 }
557596
558− return ansi.Truncate(mainParam, paramsWidth, "...")
597+ if nested {
598+ return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
599+ }
600+ return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
559601}
560602
561603// earlyState returns immediately‑rendered error/cancelled/ongoing states.
@@ -566,21 +608,21 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
566608 case v.result.IsError:
567609 message = v.renderToolError()
568610 case v.cancelled:
569− message = "Cancelled"
611+ message = t.S().Base.Padding(0, 1).Background(t.Border).Render("Cancelled")
570612 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...")
572614 default:
573615 return "", false
574616 }
575617
576618 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
578620}
579621
580622func joinHeaderBody(header, body string) string {
581623 t := styles.CurrentTheme()
582624 body = t.S().Base.PaddingLeft(2).Render(body)
583− return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
625+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
584626}
585627
586628func renderPlainContent(v *toolCallCmp, content string) string {
@@ -600,46 +642,56 @@ func renderPlainContent(v *toolCallCmp, content string) string {
600642 }
601643 out = append(out, t.S().Muted.
602644 Width(width).
603− Background(t.BgSubtle).
645+ Background(t.BgBaseLighter).
604646 Render(ln))
605647 }
606648
607649 if len(lines) > responseContextHeight {
608650 out = append(out, t.S().Muted.
609− Background(t.BgSubtle).
651+ Background(t.BgBaseLighter).
610652 Width(width).
611653 Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
612654 }
613655 return strings.Join(out, "\n")
614656}
615657
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+
616667func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
617668 t := styles.CurrentTheme()
618669 truncated := truncateHeight(content, responseContextHeight)
619670
620− highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
671+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
621672 lines := strings.Split(highlighted, "\n")
622673
623674 if len(strings.Split(content, "\n")) > responseContextHeight {
624675 lines = append(lines, t.S().Muted.
625− Background(t.BgSubtle).
626− Width(v.textWidth()-2).
627− Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
676+ Background(t.BgBase).
677+ Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
628678 }
629679
680+ maxLineNumber := len(lines) + offset
681+ padding := lipgloss.Width(fmt.Sprintf("%d", maxLineNumber))
630682 for i, ln := range lines {
631− num := t.S().Muted.
632− Background(t.BgSubtle).
633− PaddingLeft(4).
634− PaddingRight(2).
635− Render(fmt.Sprintf("%d", i+1+offset))
636− w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
683+ num := t.S().Base.
684+ Foreground(t.FgMuted).
685+ Background(t.BgBase).
686+ PaddingRight(1).
687+ PaddingLeft(1).
688+ Render(pad(i+1+offset, padding))
689+ w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding
637690 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
638691 num,
639692 t.S().Base.
640− Width(w).
641− Background(t.BgSubtle).
642− Render(v.fit(ln, w)))
693+ PaddingLeft(1).
694+ Render(v.fit(ln, w-1)))
643695 }
644696 return lipgloss.JoinVertical(lipgloss.Left, lines...)
645697}
@@ -647,8 +699,9 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
647699func (v *toolCallCmp) renderToolError() string {
648700 t := styles.CurrentTheme()
649701 err := strings.ReplaceAll(v.result.Content, "\n", " ")
650− err = fmt.Sprintf("Error: %s", err)
651− return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
702+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
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
652705}
653706
654707func truncateHeight(s string, h int) string {
@@ -662,7 +715,7 @@ func truncateHeight(s string, h int) string {
662715func prettifyToolName(name string) string {
663716 switch name {
664717 case agent.AgentToolName:
665− return "Task"
718+ return "Agent"
666719 case tools.BashToolName:
667720 return "Bash"
668721 case tools.EditToolName:
internal/tui/components/chat/messages/tool.go+15−11
@@ -25,7 +25,7 @@ type ToolCallCmp interface {
2525 SetToolResult(message.ToolResult) // Update tool result
2626 SetToolCall(message.ToolCall) // Update tool call
2727 SetCancelled() // Mark as cancelled
28− ParentMessageId() string // Get parent message ID
28+ ParentMessageID() string // Get parent message ID
2929 Spinning() bool // Animation state for pending tools
3030 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
3131 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
@@ -83,10 +83,10 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
8383
8484// NewToolCallCmp creates a new tool call component with the given parent message ID,
8585// 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 {
8787 m := &toolCallCmp{
8888 call: tc,
89− parentMessageID: parentMessageId,
89+ parentMessageID: parentMessageID,
9090 }
9191 for _, opt := range opts {
9292 opt(m)
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
137137 box := m.style()
138138
139139 if !m.call.Finished && !m.cancelled {
140− if m.isNested {
141− return tea.NewView(box.Render(m.renderPending()))
142− }
143140 return tea.NewView(box.Render(m.renderPending()))
144141 }
145142
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
166163 }
167164}
168165
169−// ParentMessageId returns the ID of the message that initiated this tool call
170−func (m *toolCallCmp) ParentMessageId() string {
166+// ParentMessageID returns the ID of the message that initiated this tool call
167+func (m *toolCallCmp) ParentMessageID() string {
171168 return m.parentMessageID
172169}
173170
@@ -210,9 +207,13 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
210207// renderPending displays the tool name with a loading animation for pending tool calls
211208func (m *toolCallCmp) renderPending() string {
212209 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+ }
213214 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214215 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())
216217}
217218
218219// style returns the lipgloss style for the tool call component.
@@ -229,14 +230,17 @@ func (m *toolCallCmp) style() lipgloss.Style {
229230// textWidth calculates the available width for text content,
230231// accounting for borders and padding
231232func (m *toolCallCmp) textWidth() int {
233+ if m.isNested {
234+ return m.width - 6
235+ }
232236 return m.width - 5 // take into account the border and PaddingLeft
233237}
234238
235239// fit truncates content to fit within the specified width with ellipsis
236240func (m *toolCallCmp) fit(content string, width int) string {
237241 t := styles.CurrentTheme()
238− lineStyle := t.S().Muted.Background(t.BgSubtle)
239− dots := lineStyle.Render("...")
242+ lineStyle := t.S().Muted
243+ dots := lineStyle.Render("…")
240244 return ansi.Truncate(content, width, dots)
241245}
242246
internal/tui/components/chat/sidebar/sidebar.go+3−3
@@ -288,9 +288,9 @@ func (m *sidebarCmp) filesBlock() string {
288288 })
289289
290290 for _, file := range files {
291− // Extract just the filename from the path
292−
293− // Create status indicators for additions/deletions
291+ if file.Additions == 0 && file.Deletions == 0 {
292+ continue // skip files with no changes
293+ }
294294 var statusParts []string
295295 if file.Additions > 0 {
296296 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 {
148148}
149149
150150func DiffFormatter() *diffview.DiffView {
151+ t := styles.CurrentTheme()
151152 formatDiff := diffview.New()
152153 style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
153− diff := formatDiff.ChromaStyle(style)
154+ diff := formatDiff.ChromaStyle(style).Style(t.S().Diff)
154155 return diff
155156}
internal/tui/components/core/list/keys.go+3−3
@@ -32,10 +32,10 @@ func DefaultKeyMap() KeyMap {
3232 key.WithKeys("k"),
3333 ),
3434 UpOneItem: key.NewBinding(
35− key.WithKeys("shift+up", "shift+k"),
35+ key.WithKeys("shift+up", "K"),
3636 ),
3737 DownOneItem: key.NewBinding(
38− key.WithKeys("shift+down", "shift+j"),
38+ key.WithKeys("shift+down", "J"),
3939 ),
4040 HalfPageDown: key.NewBinding(
4141 key.WithKeys("d"),
@@ -47,7 +47,7 @@ func DefaultKeyMap() KeyMap {
4747 key.WithKeys("g", "home"),
4848 ),
4949 End: key.NewBinding(
50− key.WithKeys("shift+g", "end"),
50+ key.WithKeys("G", "end"),
5151 ),
5252 }
5353}
internal/tui/components/core/list/list.go+4−2
@@ -749,8 +749,8 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
749749func (m *model) goToBottom() tea.Cmd {
750750 cmds := []tea.Cmd{m.blurSelected()}
751751 m.viewState.reverse = true
752+ m.selectionState.selectedIndex = m.findLastSelectableItem()
752753 if m.isFocused {
753− m.selectionState.selectedIndex = m.findLastSelectableItem()
754754 cmds = append(cmds, m.focusSelected())
755755 }
756756 m.ResetView()
@@ -764,7 +764,9 @@ func (m *model) goToTop() tea.Cmd {
764764 cmds := []tea.Cmd{m.blurSelected()}
765765 m.viewState.reverse = false
766766 m.selectionState.selectedIndex = m.findFirstSelectableItem()
767− cmds = append(cmds, m.focusSelected())
767+ if m.isFocused {
768+ cmds = append(cmds, m.focusSelected())
769+ }
768770 m.ResetView()
769771 return tea.Batch(cmds...)
770772}
internal/tui/components/core/status/status.go+27−9
@@ -1,6 +1,7 @@
11package status
22
33import (
4+ "strings"
45 "time"
56
67 "github.com/charmbracelet/bubbles/v2/help"
@@ -10,6 +11,8 @@ import (
1011 "github.com/charmbracelet/crush/internal/session"
1112 "github.com/charmbracelet/crush/internal/tui/styles"
1213 "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
15+ "github.com/charmbracelet/x/ansi"
1316)
1417
1518type StatusCmp interface {
@@ -85,6 +88,7 @@ func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8588 TTL: msg.Payload.PersistTime,
8689 }
8790 }
91+ return m, m.clearMessageCmd(m.info.TTL)
8892 }
8993 }
9094 return m, nil
@@ -94,18 +98,32 @@ func (m *statusCmp) View() tea.View {
9498 t := styles.CurrentTheme()
9599 status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
96100 if m.info.Msg != "" {
97− switch m.info.Type {
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− }
101+ status = m.infoMsg()
105102 }
106103 return tea.NewView(status)
107104}
108105
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+
109127func (m *statusCmp) ToggleFullHelp() {
110128 m.help.ShowAll = !m.help.ShowAll
111129}
@@ -119,7 +137,7 @@ func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
119137 help := help.New()
120138 help.Styles = t.S().Help
121139 return &statusCmp{
122− messageTTL: 10 * time.Second,
140+ messageTTL: 5 * time.Second,
123141 help: help,
124142 keyMap: keyMap,
125143 }
internal/tui/components/dialogs/commands/arguments.go−9
@@ -211,15 +211,6 @@ func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
211211 return cursor
212212}
213213
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−
223214func (c *commandArgumentsDialogCmp) Position() (int, int) {
224215 row := c.wHeight / 2
225216 row -= c.wHeight / 2
internal/tui/components/dialogs/models/models.go+4−4
@@ -193,15 +193,15 @@ func (m *modelDialogCmp) listHeight() int {
193193
194194func GetSelectedModel(cfg *config.Config) models.Model {
195195 agentCfg := cfg.Agents[config.AgentCoder]
196− selectedModelId := agentCfg.Model
197− return models.SupportedModels[selectedModelId]
196+ selectedModelID := agentCfg.Model
197+ return models.SupportedModels[selectedModelID]
198198}
199199
200200func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
201201 var providers []models.ModelProvider
202− for providerId, provider := range cfg.Providers {
202+ for providerID, provider := range cfg.Providers {
203203 if !provider.Disabled {
204− providers = append(providers, providerId)
204+ providers = append(providers, providerID)
205205 }
206206 }
207207
internal/tui/components/dialogs/permissions/keys.go+41−6
@@ -11,7 +11,12 @@ type KeyMap struct {
1111 Select,
1212 Allow,
1313 AllowSession,
14− Deny key.Binding
14+ Deny,
15+ ToggleDiffMode,
16+ ScrollDown,
17+ ScrollUp key.Binding
18+ ScrollLeft,
19+ ScrollRight key.Binding
1520}
1621
1722func DefaultKeyMap() KeyMap {
@@ -41,9 +46,29 @@ func DefaultKeyMap() KeyMap {
4146 key.WithHelp("d", "deny"),
4247 ),
4348 Select: key.NewBinding(
44− key.WithKeys("enter", "tab", "ctrl+y"),
49+ key.WithKeys("enter", "ctrl+y"),
4550 key.WithHelp("enter", "confirm"),
4651 ),
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+ ),
4772 }
4873}
4974
@@ -57,6 +82,11 @@ func (k KeyMap) KeyBindings() []key.Binding {
5782 k.Allow,
5883 k.AllowSession,
5984 k.Deny,
85+ k.ToggleDiffMode,
86+ k.ScrollDown,
87+ k.ScrollUp,
88+ k.ScrollLeft,
89+ k.ScrollRight,
6090 }
6191}
6292
@@ -74,9 +104,14 @@ func (k KeyMap) FullHelp() [][]key.Binding {
74104// ShortHelp implements help.KeyMap.
75105func (k KeyMap) ShortHelp() []key.Binding {
76106 return []key.Binding{
77− k.Allow,
78− k.AllowSession,
79− k.Deny,
80− k.Select,
107+ k.ToggleDiffMode,
108+ key.NewBinding(
109+ key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
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+ ),
81116 }
82117}
internal/tui/components/dialogs/permissions/permissions.go+37−4
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "strings"
66
7+ "github.com/charmbracelet/bubbles/v2/help"
78 "github.com/charmbracelet/bubbles/v2/key"
89 "github.com/charmbracelet/bubbles/v2/viewport"
910 tea "github.com/charmbracelet/bubbletea/v2"
@@ -50,6 +51,11 @@ type permissionDialogCmp struct {
5051 contentViewPort viewport.Model
5152 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
5253
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+
5359 keyMap KeyMap
5460}
5561
@@ -101,6 +107,21 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101107 util.CmdHandler(dialogs.CloseDialogMsg{}),
102108 util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
103109 )
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
104125 default:
105126 // Pass other keys to viewport
106127 viewPort, cmd := p.contentViewPort.Update(msg)
@@ -269,8 +290,15 @@ func (p *permissionDialogCmp) renderEditContent() string {
269290 formatter := core.DiffFormatter().
270291 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
271292 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
293+ Height(p.contentViewPort.Height()).
272294 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+ }
274302
275303 diff := formatter.String()
276304 contentHeight := min(p.height-9, lipgloss.Height(diff))
@@ -367,11 +395,13 @@ func (p *permissionDialogCmp) render() string {
367395
368396 // Render content based on tool type
369397 var contentFinal string
398+ var contentHelp string
370399 switch p.permission.ToolName {
371400 case tools.BashToolName:
372401 contentFinal = p.renderBashContent()
373402 case tools.EditToolName:
374403 contentFinal = p.renderEditContent()
404+ contentHelp = help.New().View(p.keyMap)
375405 case tools.WriteToolName:
376406 contentFinal = p.renderWriteContent()
377407 case tools.FetchToolName:
@@ -381,8 +411,7 @@ func (p *permissionDialogCmp) render() string {
381411 }
382412 // Calculate content height dynamically based on window size
383413
384− content := lipgloss.JoinVertical(
385− lipgloss.Top,
414+ strs := []string{
386415 title,
387416 "",
388417 headerContent,
@@ -390,7 +419,11 @@ func (p *permissionDialogCmp) render() string {
390419 "",
391420 buttons,
392421 "",
393− )
422+ }
423+ if contentHelp != "" {
424+ strs = append(strs, "", contentHelp)
425+ }
426+ content := lipgloss.JoinVertical(lipgloss.Top, strs...)
394427
395428 return baseStyle.
396429 Padding(0, 1).
internal/tui/keys.go+2−2
@@ -61,8 +61,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
6161 }
6262 }
6363
64− for i := 0; i < len(cleaned); i += 2 {
65− end := min(i+2, len(cleaned))
64+ for i := 0; i < len(cleaned); i += 3 {
65+ end := min(i+3, len(cleaned))
6666 m = append(m, cleaned[i:end])
6767 }
6868 return m
internal/tui/page/chat/chat.go+32−5
@@ -3,6 +3,7 @@ package chat
33import (
44 "context"
55 "strings"
6+ "time"
67
78 "github.com/charmbracelet/bubbles/v2/key"
89 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,6 +34,7 @@ type (
3334 ChatFocusedMsg struct {
3435 Focused bool // True if the chat input is focused, false otherwise
3536 }
37+ CancelTimerExpiredMsg struct{}
3638)
3739
3840type ChatPage interface {
@@ -57,6 +59,8 @@ type chatPage struct {
5759 showDetails bool // Show details in the header
5860 header header.Header
5961 compactSidebar layout.Container
62+
63+ cancelPending bool // True if ESC was pressed once and waiting for second press
6064}
6165
6266func (p *chatPage) Init() tea.Cmd {
@@ -67,9 +71,19 @@ func (p *chatPage) Init() tea.Cmd {
6771 )
6872}
6973
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+
7081func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7182 var cmds []tea.Cmd
7283 switch msg := msg.(type) {
84+ case CancelTimerExpiredMsg:
85+ p.cancelPending = false
86+ return p, nil
7387 case tea.WindowSizeMsg:
7488 h, cmd := p.header.Update(msg)
7589 cmds = append(cmds, cmd)
@@ -181,10 +195,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181195 return p, tea.Batch(cmds...)
182196 case key.Matches(msg, p.keyMap.Cancel):
183197 if p.session.ID != "" {
184− // Cancel the current session's generation process
185− // This allows users to interrupt long-running operations
186− p.app.CoderAgent.Cancel(p.session.ID)
187− return p, nil
198+ if p.cancelPending {
199+ // Second ESC press - actually cancel the session
200+ p.cancelPending = false
201+ p.app.CoderAgent.Cancel(p.session.ID)
202+ return p, nil
203+ } else {
204+ // First ESC press - start the timer
205+ p.cancelPending = true
206+ return p, p.cancelTimerCmd()
207+ }
188208 }
189209 case key.Matches(msg, p.keyMap.Details):
190210 if p.session.ID == "" || !p.compactMode {
@@ -336,7 +356,14 @@ func (p *chatPage) Bindings() []key.Binding {
336356 p.keyMap.AddAttachment,
337357 }
338358 if p.app.CoderAgent.IsBusy() {
339− bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
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+ }
366+ bindings = append([]key.Binding{cancelBinding}, bindings...)
340367 }
341368
342369 if p.chatFocused {
internal/tui/styles/crush.go+8−4
@@ -14,9 +14,10 @@ func NewCrushTheme() *Theme {
1414 Tertiary: charmtone.Bok,
1515 Accent: charmtone.Zest,
1616 // Backgrounds
17− BgBase: charmtone.Pepper,
18− BgSubtle: charmtone.Charcoal,
19− BgOverlay: charmtone.Iron,
17+ BgBase: charmtone.Pepper,
18+ BgBaseLighter: Lighten(charmtone.Pepper, 2),
19+ BgSubtle: charmtone.Charcoal,
20+ BgOverlay: charmtone.Iron,
2021
2122 // Foregrounds
2223 FgBase: charmtone.Ash,
@@ -38,7 +39,10 @@ func NewCrushTheme() *Theme {
3839 // Colors
3940 White: charmtone.Butter,
4041
41− Blue: charmtone.Malibu,
42+ BlueLight: charmtone.Sardine,
43+ Blue: charmtone.Malibu,
44+
45+ Yellow: charmtone.Mustard,
4246
4347 Green: charmtone.Julep,
4448 GreenDark: charmtone.Guac,
internal/tui/styles/theme.go+55−37
@@ -10,6 +10,7 @@ import (
1010 "github.com/charmbracelet/bubbles/v2/textarea"
1111 "github.com/charmbracelet/bubbles/v2/textinput"
1212 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/exp/diffview"
1314 "github.com/charmbracelet/glamour/v2/ansi"
1415 "github.com/charmbracelet/lipgloss/v2"
1516 "github.com/lucasb-eyer/go-colorful"
@@ -31,9 +32,10 @@ type Theme struct {
3132 Tertiary color.Color
3233 Accent color.Color
3334
34− BgBase color.Color
35− BgSubtle color.Color
36− BgOverlay color.Color
35+ BgBase color.Color
36+ BgBaseLighter color.Color
37+ BgSubtle color.Color
38+ BgOverlay color.Color
3739
3840 FgBase color.Color
3941 FgMuted color.Color
@@ -52,8 +54,13 @@ type Theme struct {
5254 // Colors
5355 // White
5456 White color.Color
57+
5558 // Blues
56− Blue color.Color
59+ BlueLight color.Color
60+ Blue color.Color
61+
62+ // Yellows
63+ Yellow color.Color
5764
5865 // Greens
5966 Green color.Color
@@ -65,26 +72,9 @@ type Theme struct {
6572 RedDark color.Color
6673 RedLight color.Color
6774
68− // TODO: add any others needed
69−
7075 styles *Styles
7176}
7277
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−
8878type Styles struct {
8979 Base lipgloss.Style
9080 SelectedBase lipgloss.Style
@@ -112,7 +102,7 @@ type Styles struct {
112102 Help help.Styles
113103
114104 // Diff
115− Diff Diff
105+ Diff diffview.Style
116106
117107 // FilePicker
118108 FilePicker filepicker.Styles
@@ -421,22 +411,50 @@ func (t *Theme) buildStyles() *Styles {
421411 FullSeparator: base.Foreground(t.Border),
422412 },
423413
424− // TODO: Fix this this is bad
425− Diff: Diff{
426− Added: t.Green,
427− Removed: t.Red,
428− Context: t.FgSubtle,
429− HunkHeader: t.FgSubtle,
430− HighlightAdded: t.GreenLight,
431− HighlightRemoved: t.RedLight,
432− AddedBg: t.GreenDark,
433− RemovedBg: t.RedDark,
434− ContextBg: t.BgSubtle,
435− LineNumber: t.FgMuted,
436− AddedLineNumberBg: t.GreenDark,
437− RemovedLineNumberBg: t.RedDark,
414+ Diff: diffview.Style{
415+ DividerLine: diffview.LineStyle{
416+ LineNumber: lipgloss.NewStyle().
417+ Foreground(t.FgHalfMuted).
418+ Background(t.BgBaseLighter),
419+ Code: lipgloss.NewStyle().
420+ Foreground(t.FgHalfMuted).
421+ Background(t.BgBaseLighter),
422+ },
423+ MissingLine: diffview.LineStyle{
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{
448+ LineNumber: lipgloss.NewStyle().
449+ Foreground(lipgloss.Color("#a45c59")).
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+ },
438457 },
439−
440458 FilePicker: filepicker.Styles{
441459 DisabledCursor: base.Foreground(t.FgMuted),
442460 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) {
9494
9595 switch msg := msg.(type) {
9696 case tea.KeyboardEnhancementsMsg:
97− logging.Info(
98− "Keyboard enhancements detected",
99− "Disambiguation", msg.SupportsKeyDisambiguation(),
100− "ReleaseKeys", msg.SupportsKeyReleases(),
101− "UniformKeys", msg.SupportsUniformKeyLayout(),
102− )
10397 return a, nil
10498 case tea.WindowSizeMsg:
10599 return a, a.handleWindowResize(msg.Width, msg.Height)
@@ -260,7 +254,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
260254 var cmds []tea.Cmd
261255 a.wWidth, a.wHeight = width, height
262256 if a.showingFullHelp {
263− height -= 3
257+ height -= 4
264258 } else {
265259 height -= 2
266260 }
todos.md+9
@@ -20,7 +20,16 @@
2020- [ ] Parallel tool calls and permissions
2121 - [ ] Run the tools in parallel and add results in parallel
2222 - [ ] 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
2330- [ ] Investigate messages issues
31+ - [ ] Make the agent separator look like the
32+ - [ ] Cleanup tool calls (watch all states)
2433 - [ ] Weird behavior sometimes the message does not update
2534 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
2635 - [ ] Address UX issues