Merge remote-tracking branch 'origin/main' into ref

Carlos Alexandro Becker created

Change summary

cspell.json                                                                           |   2 
internal/app/app.go                                                                   |   1 
internal/exp/diffview/Taskfile.yaml                                                   |  20 
internal/exp/diffview/diffview.go                                                     | 412 
internal/exp/diffview/diffview_test.go                                                |  27 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden         |   2 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden           |   1 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden           |   2 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden           |   2 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden           |   7 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden           |   7 
internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden           |   7 
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden         |   4 
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden         |   6 
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden         |   8 
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden         |  10 
internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden         |  10 
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   |   3 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden   |   2 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden   |   1 
internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf12.golden   |   0 
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                                                          |  12 
internal/llm/tools/bash.go                                                            |   3 
internal/tui/components/chat/chat.go                                                  |  17 
internal/tui/components/chat/messages/messages.go                                     | 108 
internal/tui/components/chat/messages/renderer.go                                     | 235 
internal/tui/components/chat/messages/tool.go                                         |  26 
internal/tui/components/chat/sidebar/sidebar.go                                       |   6 
internal/tui/components/core/helpers.go                                               |   3 
internal/tui/components/core/list/keys.go                                             |   6 
internal/tui/components/core/list/list.go                                             |   6 
internal/tui/components/core/status/status.go                                         |  36 
internal/tui/components/dialogs/commands/arguments.go                                 |   9 
internal/tui/components/dialogs/models/models.go                                      |   8 
internal/tui/components/dialogs/permissions/keys.go                                   |  47 
internal/tui/components/dialogs/permissions/permissions.go                            |  41 
internal/tui/keys.go                                                                  |   4 
internal/tui/page/chat/chat.go                                                        |  37 
internal/tui/styles/crush.go                                                          |  12 
internal/tui/styles/theme.go                                                          |  92 
internal/tui/tui.go                                                                   |   8 
todos.md                                                                              |   9 
92 files changed, 970 insertions(+), 485 deletions(-)

Detailed changes

cspell.json 🔗

@@ -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"]}
+{"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 🔗

@@ -163,4 +163,5 @@ func (app *App) Shutdown() {
 		}
 		cancel()
 	}
+	app.CoderAgent.CancelAll()
 }

internal/exp/diffview/Taskfile.yaml 🔗

@@ -100,3 +100,23 @@ tasks:
       - for: sources
         cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
     silent: true
+
+  test:print:yoffset:unified:infinite:
+    desc: Print golden files for debugging
+    method: none
+    sources:
+      - ./testdata/TestDiffViewYOffsetInfinite/Unified/*.golden
+    cmds:
+      - for: sources
+        cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
+    silent: true
+
+  test:print:yoffset:split:infinite:
+    desc: Print golden files for debugging
+    method: none
+    sources:
+      - ./testdata/TestDiffViewYOffsetInfinite/Split/*.golden
+    cmds:
+      - for: sources
+        cmd: echo && echo "------- {{.ITEM}} -------" && echo && cat {{.ITEM}}
+    silent: true

internal/exp/diffview/diffview.go 🔗

@@ -33,18 +33,19 @@ const (
 
 // DiffView represents a view for displaying differences between two files.
 type DiffView struct {
-	layout       layout
-	before       file
-	after        file
-	contextLines int
-	lineNumbers  bool
-	height       int
-	width        int
-	xOffset      int
-	yOffset      int
-	style        Style
-	tabWidth     int
-	chromaStyle  *chroma.Style
+	layout          layout
+	before          file
+	after           file
+	contextLines    int
+	lineNumbers     bool
+	height          int
+	width           int
+	xOffset         int
+	yOffset         int
+	infiniteYScroll bool
+	style           Style
+	tabWidth        int
+	chromaStyle     *chroma.Style
 
 	isComputed bool
 	err        error
@@ -53,6 +54,7 @@ type DiffView struct {
 
 	splitHunks []splitHunk
 
+	totalLines      int
 	codeWidth       int
 	fullCodeWidth   int  // with leading symbols
 	extraColOnAfter bool // add extra column on after panel
@@ -138,6 +140,12 @@ func (dv *DiffView) YOffset(yOffset int) *DiffView {
 	return dv
 }
 
+// InfiniteYScroll allows the YOffset to scroll beyond the last line.
+func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
+	dv.infiniteYScroll = infiniteYScroll
+	return dv
+}
+
 // TabWidth sets the tab width. Only relevant for code that contains tabs, like
 // Go code.
 func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
@@ -161,6 +169,8 @@ func (dv *DiffView) String() string {
 	dv.convertDiffToSplit()
 	dv.adjustStyles()
 	dv.detectNumDigits()
+	dv.detectTotalLines()
+	dv.preventInfiniteYScroll()
 
 	if dv.width <= 0 {
 		dv.detectCodeWidth()
@@ -251,6 +261,37 @@ func (dv *DiffView) detectNumDigits() {
 	}
 }
 
+func (dv *DiffView) detectTotalLines() {
+	dv.totalLines = 0
+
+	switch dv.layout {
+	case layoutUnified:
+		for _, h := range dv.unified.Hunks {
+			dv.totalLines += 1 + len(h.Lines)
+		}
+	case layoutSplit:
+		for _, h := range dv.splitHunks {
+			dv.totalLines += 1 + len(h.lines)
+		}
+	}
+}
+
+func (dv *DiffView) preventInfiniteYScroll() {
+	if dv.infiniteYScroll {
+		return
+	}
+
+	// clamp yOffset to prevent scrolling beyond the last line
+	if dv.height > 0 {
+		maxYOffset := max(0, dv.totalLines-dv.height)
+		dv.yOffset = min(dv.yOffset, maxYOffset)
+	} else {
+		// if no height limit, ensure yOffset doesn't exceed total lines
+		dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
+	}
+	dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
+}
+
 // detectCodeWidth calculates the maximum width of code lines in the diff view.
 func (dv *DiffView) detectCodeWidth() {
 	switch dv.layout {
@@ -321,23 +362,29 @@ func (dv *DiffView) renderUnified() string {
 
 	fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
 	printedLines := -dv.yOffset
-
-	write := func(s string) {
-		if printedLines >= 0 {
-			b.WriteString(s)
-		}
+	shouldWrite := func() bool { return printedLines >= 0 }
+
+	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
+		content = strings.TrimSuffix(in, "\n")
+		content = dv.hightlightCode(content, ls.Code.GetBackground())
+		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
+		content = ansi.Truncate(content, dv.codeWidth, "…")
+		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
+		return
 	}
 
 outer:
 	for i, h := range dv.unified.Hunks {
-		ls := dv.style.DividerLine
-		if dv.lineNumbers {
-			write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
-			write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+		if shouldWrite() {
+			ls := dv.style.DividerLine
+			if dv.lineNumbers {
+				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
+				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+			}
+			content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
+			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
+			b.WriteString("\n")
 		}
-		content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
-		write(ls.Code.Width(dv.fullCodeWidth).Render(content))
-		write("\n")
 		printedLines++
 
 		beforeLine := h.FromLine
@@ -349,80 +396,82 @@ outer:
 			isLastHunk := i+1 == len(dv.unified.Hunks)
 			isLastLine := j+1 == len(h.Lines)
 			if hasReachedHeight && (!isLastHunk || !isLastLine) {
-				ls := dv.lineStyleForType(l.Kind)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
-					write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.lineStyleForType(l.Kind)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
+						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+					}
+					b.WriteString(fullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
+					))
+					b.WriteRune('\n')
 				}
-				write(fullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth).Render("  …"),
-				))
-				write("\n")
 				break outer
 			}
 
-			getContent := func(ls LineStyle) string {
-				content := strings.TrimSuffix(l.Content, "\n")
-				content = dv.hightlightCode(content, ls.Code.GetBackground())
-				content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
-				content = ansi.Truncate(content, dv.codeWidth, "…")
-				return content
-			}
-
-			leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
-
 			switch l.Kind {
 			case udiff.Equal:
-				ls := dv.style.EqualLine
-				content := getContent(ls)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
-					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.EqualLine
+					content, leadingEllipsis := getContent(l.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
+						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+					}
+					b.WriteString(fullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
+					))
 				}
-				write(fullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
-				))
 				beforeLine++
 				afterLine++
 			case udiff.Insert:
-				ls := dv.style.InsertLine
-				content := getContent(ls)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
-					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.InsertLine
+					content, leadingEllipsis := getContent(l.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
+						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+					}
+					b.WriteString(fullContentStyle.Render(
+						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
+							ls.Code.Width(dv.codeWidth).Render(content),
+					))
 				}
-				write(fullContentStyle.Render(
-					ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
-						ls.Code.Width(dv.codeWidth).Render(content),
-				))
 				afterLine++
 			case udiff.Delete:
-				ls := dv.style.DeleteLine
-				content := getContent(ls)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
-					write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.DeleteLine
+					content, leadingEllipsis := getContent(l.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
+						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+					}
+					b.WriteString(fullContentStyle.Render(
+						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
+							ls.Code.Width(dv.codeWidth).Render(content),
+					))
 				}
-				write(fullContentStyle.Render(
-					ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
-						ls.Code.Width(dv.codeWidth).Render(content),
-				))
 				beforeLine++
 			}
-			write("\n")
+			if shouldWrite() {
+				b.WriteRune('\n')
+			}
 
 			printedLines++
 		}
 	}
 
 	for printedLines < dv.height {
-		ls := dv.style.MissingLine
-		if dv.lineNumbers {
-			write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
-			write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+		if shouldWrite() {
+			ls := dv.style.MissingLine
+			if dv.lineNumbers {
+				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
+				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+			}
+			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render("  "))
+			b.WriteRune('\n')
 		}
-		write(ls.Code.Width(dv.fullCodeWidth).Render("  "))
-		write("\n")
 		printedLines++
 	}
 
@@ -436,26 +485,32 @@ func (dv *DiffView) renderSplit() string {
 	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
 	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
 	printedLines := -dv.yOffset
-
-	write := func(s string) {
-		if printedLines >= 0 {
-			b.WriteString(s)
-		}
+	shouldWrite := func() bool { return printedLines >= 0 }
+
+	getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
+		content = strings.TrimSuffix(in, "\n")
+		content = dv.hightlightCode(content, ls.Code.GetBackground())
+		content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
+		content = ansi.Truncate(content, dv.codeWidth, "…")
+		leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
+		return
 	}
 
 outer:
 	for i, h := range dv.splitHunks {
-		ls := dv.style.DividerLine
-		if dv.lineNumbers {
-			write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
-		}
-		content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
-		write(ls.Code.Width(dv.fullCodeWidth).Render(content))
-		if dv.lineNumbers {
-			write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+		if shouldWrite() {
+			ls := dv.style.DividerLine
+			if dv.lineNumbers {
+				b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
+			}
+			content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
+			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
+			if dv.lineNumbers {
+				b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+			}
+			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
+			b.WriteRune('\n')
 		}
-		write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
-		write("\n")
 		printedLines++
 
 		beforeLine := h.fromLine
@@ -467,126 +522,129 @@ outer:
 			isLastHunk := i+1 == len(dv.unified.Hunks)
 			isLastLine := j+1 == len(h.lines)
 			if hasReachedHeight && (!isLastHunk || !isLastLine) {
-				ls := dv.style.MissingLine
-				if l.before != nil {
-					ls = dv.lineStyleForType(l.before.Kind)
-				}
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
-				}
-				write(beforeFullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth).Render("  …"),
-				))
-				ls = dv.style.MissingLine
-				if l.after != nil {
-					ls = dv.lineStyleForType(l.after.Kind)
+				if shouldWrite() {
+					ls := dv.style.MissingLine
+					if l.before != nil {
+						ls = dv.lineStyleForType(l.before.Kind)
+					}
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
+					}
+					b.WriteString(beforeFullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
+					))
+					ls = dv.style.MissingLine
+					if l.after != nil {
+						ls = dv.lineStyleForType(l.after.Kind)
+					}
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
+					}
+					b.WriteString(afterFullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth).Render("  …"),
+					))
+					b.WriteRune('\n')
 				}
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
-				}
-				write(afterFullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth).Render("  …"),
-				))
-				write("\n")
 				break outer
 			}
 
-			getContent := func(content string, ls LineStyle) string {
-				content = strings.TrimSuffix(content, "\n")
-				content = dv.hightlightCode(content, ls.Code.GetBackground())
-				content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
-				content = ansi.Truncate(content, dv.codeWidth, "…")
-				return content
-			}
-			getLeadingEllipsis := func(content string) bool {
-				return dv.xOffset > 0 && strings.TrimSpace(content) != ""
-			}
-
 			switch {
 			case l.before == nil:
-				ls := dv.style.MissingLine
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.MissingLine
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
+					}
+					b.WriteString(beforeFullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth).Render("  "),
+					))
 				}
-				write(beforeFullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth).Render("  "),
-				))
 			case l.before.Kind == udiff.Equal:
-				ls := dv.style.EqualLine
-				content := getContent(l.before.Content, ls)
-				leadingEllipsis := getLeadingEllipsis(content)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.EqualLine
+					content, leadingEllipsis := getContent(l.before.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
+					}
+					b.WriteString(beforeFullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
+					))
 				}
-				write(beforeFullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
-				))
 				beforeLine++
 			case l.before.Kind == udiff.Delete:
-				ls := dv.style.DeleteLine
-				content := getContent(l.before.Content, ls)
-				leadingEllipsis := getLeadingEllipsis(content)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.DeleteLine
+					content, leadingEllipsis := getContent(l.before.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
+					}
+					b.WriteString(beforeFullContentStyle.Render(
+						ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
+							ls.Code.Width(dv.codeWidth).Render(content),
+					))
 				}
-				write(beforeFullContentStyle.Render(
-					ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
-						ls.Code.Width(dv.codeWidth).Render(content),
-				))
 				beforeLine++
 			}
 
 			switch {
 			case l.after == nil:
-				ls := dv.style.MissingLine
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.MissingLine
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+					}
+					b.WriteString(afterFullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
+					))
 				}
-				write(afterFullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
-				))
 			case l.after.Kind == udiff.Equal:
-				ls := dv.style.EqualLine
-				content := getContent(l.after.Content, ls)
-				leadingEllipsis := getLeadingEllipsis(content)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.EqualLine
+					content, leadingEllipsis := getContent(l.after.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+					}
+					b.WriteString(afterFullContentStyle.Render(
+						ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", "  ") + content),
+					))
 				}
-				write(afterFullContentStyle.Render(
-					ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", "  ") + content),
-				))
 				afterLine++
 			case l.after.Kind == udiff.Insert:
-				ls := dv.style.InsertLine
-				content := getContent(l.after.Content, ls)
-				leadingEllipsis := getLeadingEllipsis(content)
-				if dv.lineNumbers {
-					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+				if shouldWrite() {
+					ls := dv.style.InsertLine
+					content, leadingEllipsis := getContent(l.after.Content, ls)
+					if dv.lineNumbers {
+						b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
+					}
+					b.WriteString(afterFullContentStyle.Render(
+						ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
+							ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
+					))
 				}
-				write(afterFullContentStyle.Render(
-					ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
-						ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
-				))
 				afterLine++
 			}
 
-			write("\n")
+			if shouldWrite() {
+				b.WriteRune('\n')
+			}
 
 			printedLines++
 		}
 	}
 
 	for printedLines < dv.height {
-		ls := dv.style.MissingLine
-		if dv.lineNumbers {
-			write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
-		}
-		write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
-		if dv.lineNumbers {
-			write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+		if shouldWrite() {
+			ls := dv.style.MissingLine
+			if dv.lineNumbers {
+				b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
+			}
+			b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(" "))
+			if dv.lineNumbers {
+				b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
+			}
+			b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
+			b.WriteRune('\n')
 		}
-		write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
-		write("\n")
 		printedLines++
 	}
 

internal/exp/diffview/diffview_test.go 🔗

@@ -283,6 +283,33 @@ func TestDiffViewYOffset(t *testing.T) {
 	}
 }
 
+func TestDiffViewYOffsetInfinite(t *testing.T) {
+	for layoutName, layoutFunc := range LayoutFuncs {
+		t.Run(layoutName, func(t *testing.T) {
+			for yOffset := range 17 {
+				t.Run(fmt.Sprintf("YOffsetOf%02d", yOffset), func(t *testing.T) {
+					t.Parallel()
+
+					dv := diffview.New().
+						Before("main.go", TestMultipleHunksBefore).
+						After("main.go", TestMultipleHunksAfter).
+						Style(diffview.DefaultLightStyle()).
+						ChromaStyle(styles.Get("catppuccin-latte")).
+						Height(5).
+						YOffset(yOffset).
+						InfiniteYScroll(true)
+					dv = layoutFunc(dv)
+
+					output := dv.String()
+					golden.RequireEqual(t, []byte(output))
+
+					assertHeight(t, 5, output)
+				})
+			}
+		})
+	}
+}
+
 func assertLineWidth(t *testing.T, expected int, output string) {
 	var lineWidth int
 	for line := range strings.SplitSeq(output, "\n") {

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf01.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …unc main() {                                      
   8     -…   fmt.Println("Hello, world!")                   
       8 +…   content := "Hello, world!"                     

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf02.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …nc main() {                                       
   8     -…  fmt.Println("Hello, world!")                    
       8 +…  content := "Hello, world!"                      

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf03.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …c main() {                                        
   8     -… fmt.Println("Hello, world!")                     
       8 +… content := "Hello, world!"                       

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf04.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  … main() {                                         
   8     -…fmt.Println("Hello, world!")                      
       8 +…content := "Hello, world!"                        

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf05.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …main() {                                          
   8     -…mt.Println("Hello, world!")                       
       8 +…ontent := "Hello, world!"                         

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf06.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …ain() {                                           
   8     -…t.Println("Hello, world!")                        
       8 +…ntent := "Hello, world!"                          

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf07.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …in() {                                            
   8     -….Println("Hello, world!")                         
       8 +…tent := "Hello, world!"                           

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf08.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …n() {                                             
   8     -…Println("Hello, world!")                          
       8 +…ent := "Hello, world!"                            

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf09.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …() {                                              
   8     -…rintln("Hello, world!")                           
       8 +…nt := "Hello, world!"                             

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf10.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …) {                                               
   8     -…intln("Hello, world!")                            
       8 +…t := "Hello, world!"                              

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf11.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  … {                                                
   8     -…ntln("Hello, world!")                             
       8 +… := "Hello, world!"                               

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf12.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …{                                                 
   8     -…tln("Hello, world!")                              
       8 +…:= "Hello, world!"                                

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf13.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…ln("Hello, world!")                               
       8 +…= "Hello, world!"                                 

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf14.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…n("Hello, world!")                                
       8 +… "Hello, world!"                                  

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf15.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…("Hello, world!")                                 
       8 +…"Hello, world!"                                   

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf16.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…"Hello, world!")                                  
       8 +…Hello, world!"                                    

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf17.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…Hello, world!")                                   
       8 +…ello, world!"                                     

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf18.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…ello, world!")                                    
       8 +…llo, world!"                                      

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf19.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…llo, world!")                                     
       8 +…lo, world!"                                       

internal/exp/diffview/testdata/TestDiffViewXOffset/Unified/XOffsetOf20.golden 🔗

@@ -1,6 +1,6 @@
   …   …   @@ -5,5 +5,6 @@                                   
   5   5  …                                                  
-  6   6  …                                                  
+  6   6                                                     
   7   7  …                                                  
   8     -…lo, world!")                                      
       8 +…o, world!"                                        

internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf11.golden 🔗

@@ -1,5 +1,5 @@
+ 10                                                    11                                                   
  11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf12.golden 🔗

@@ -1,5 +1,5 @@
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf13.golden 🔗

@@ -1,5 +1,5 @@
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf14.golden 🔗

@@ -1,5 +1,5 @@
- 13   }                                                15   }                                               
-                                                                                                            
-                                                                                                            
-                                                                                                            
-                                                                                                            
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf15.golden 🔗

@@ -1,5 +1,5 @@
-                                                                                                            
-                                                                                                            
-                                                                                                            
-                                                                                                            
-                                                                                                            
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffset/Split/YOffsetOf16.golden 🔗

@@ -1,5 +1,5 @@
-                                                                                                            
-                                                                                                            
-                                                                                                            
-                                                                                                            
-                                                                                                            
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf12.golden 🔗

@@ -1,5 +1,5 @@
+ 11  12   func getContent() string {                      
  12     -     return "Hello, world!"                      
      13 +     content := strings.ToUpper("Hello, World!") 
      14 +     return content                              
- 13  15   }                                               
-                                                          
+ 13  15   }                                               

internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf13.golden 🔗

@@ -1,5 +1,5 @@
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
      13 +     content := strings.ToUpper("Hello, World!") 
      14 +     return content                              
- 13  15   }                                               
-                                                          
-                                                          
+ 13  15   }                                               

internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf14.golden 🔗

@@ -1,5 +1,5 @@
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
+     13 +     content := strings.ToUpper("Hello, World!") 
      14 +     return content                              
- 13  15   }                                               
-                                                          
-                                                          
-                                                          
+ 13  15   }                                               

internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf15.golden 🔗

@@ -1,5 +1,5 @@
- 13  15   }                                               
-                                                          
-                                                          
-                                                          
-                                                          
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
+     13 +     content := strings.ToUpper("Hello, World!") 
+     14 +     return content                              
+ 13  15   }                                               

internal/exp/diffview/testdata/TestDiffViewYOffset/Unified/YOffsetOf16.golden 🔗

@@ -1,5 +1,5 @@
-                                                          
-                                                          
-                                                          
-                                                          
-                                                          
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
+     13 +     content := strings.ToUpper("Hello, World!") 
+     14 +     return content                              
+ 13  15   }                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf00.golden 🔗

@@ -0,0 +1,5 @@
+  …   @@ -2,6 +2,7 @@                                   …                                                   
+  2                                                     2                                                   
+  3   import (                                          3   import (                                        
+  4       "fmt"                                         4       "fmt"                                       
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf01.golden 🔗

@@ -0,0 +1,5 @@
+  2                                                     2                                                   
+  3   import (                                          3   import (                                        
+  4       "fmt"                                         4       "fmt"                                       
+                                                        5 +     "strings"                                   
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf02.golden 🔗

@@ -0,0 +1,5 @@
+  3   import (                                          3   import (                                        
+  4       "fmt"                                         4       "fmt"                                       
+                                                        5 +     "strings"                                   
+  5   )                                                 6   )                                               
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf03.golden 🔗

@@ -0,0 +1,5 @@
+  4       "fmt"                                         4       "fmt"                                       
+                                                        5 +     "strings"                                   
+  5   )                                                 6   )                                               
+  6                                                     7                                                   
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf04.golden 🔗

@@ -0,0 +1,5 @@
+                                                        5 +     "strings"                                   
+  5   )                                                 6   )                                               
+  6                                                     7                                                   
+  7   func main() {                                     8   func main() {                                   
+  …   @@ -9,5 +10,6 @@                                  …                                                   

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf05.golden 🔗

@@ -0,0 +1,5 @@
+  5   )                                                 6   )                                               
+  6                                                     7                                                   
+  7   func main() {                                     8   func main() {                                   
+  …   @@ -9,5 +10,6 @@                                  …                                                   
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf06.golden 🔗

@@ -0,0 +1,5 @@
+  6                                                     7                                                   
+  7   func main() {                                     8   func main() {                                   
+  …   @@ -9,5 +10,6 @@                                  …                                                   
+  9   }                                                10   }                                               
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf07.golden 🔗

@@ -0,0 +1,5 @@
+  7   func main() {                                     8   func main() {                                   
+  …   @@ -9,5 +10,6 @@                                  …                                                   
+  9   }                                                10   }                                               
+ 10                                                    11                                                   
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf08.golden 🔗

@@ -0,0 +1,5 @@
+  …   @@ -9,5 +10,6 @@                                  …                                                   
+  9   }                                                10   }                                               
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      
+  …   …                                                 …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf09.golden 🔗

@@ -0,0 +1,5 @@
+  9   }                                                10   }                                               
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf10.golden 🔗

@@ -0,0 +1,5 @@
+ 10                                                    11                                                   
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf11.golden 🔗

@@ -0,0 +1,5 @@
+ 11   func getContent() string {                       12   func getContent() string {                      

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf13.golden 🔗

@@ -0,0 +1,5 @@
+                                                       14 +     return content                              
+ 13   }                                                15   }                                               
+                                                                                                            
+                                                                                                            
+                                                                                                            

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf14.golden 🔗

@@ -0,0 +1,5 @@
+ 13   }                                                15   }                                               
+                                                                                                            
+                                                                                                            
+                                                                                                            
+                                                                                                            

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf15.golden 🔗

@@ -0,0 +1,5 @@
+                                                                                                            
+                                                                                                            
+                                                                                                            
+                                                                                                            
+                                                                                                            

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Split/YOffsetOf16.golden 🔗

@@ -0,0 +1,5 @@
+                                                                                                            
+                                                                                                            
+                                                                                                            
+                                                                                                            
+                                                                                                            

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf00.golden 🔗

@@ -0,0 +1,5 @@
+  …   …   @@ -2,6 +2,7 @@                                 
+  2   2                                                   
+  3   3   import (                                        
+  4   4       "fmt"                                       
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf01.golden 🔗

@@ -0,0 +1,5 @@
+  2   2                                                   
+  3   3   import (                                        
+  4   4       "fmt"                                       
+      5 +     "strings"                                   
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf02.golden 🔗

@@ -0,0 +1,5 @@
+  3   3   import (                                        
+  4   4       "fmt"                                       
+      5 +     "strings"                                   
+  5   6   )                                               
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf03.golden 🔗

@@ -0,0 +1,5 @@
+  4   4       "fmt"                                       
+      5 +     "strings"                                   
+  5   6   )                                               
+  6   7                                                   
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf04.golden 🔗

@@ -0,0 +1,5 @@
+      5 +     "strings"                                   
+  5   6   )                                               
+  6   7                                                   
+  7   8   func main() {                                   
+  …   …   @@ -9,5 +10,6 @@                                

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf05.golden 🔗

@@ -0,0 +1,5 @@
+  5   6   )                                               
+  6   7                                                   
+  7   8   func main() {                                   
+  …   …   @@ -9,5 +10,6 @@                                
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf06.golden 🔗

@@ -0,0 +1,5 @@
+  6   7                                                   
+  7   8   func main() {                                   
+  …   …   @@ -9,5 +10,6 @@                                
+  9  10   }                                               
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf07.golden 🔗

@@ -0,0 +1,5 @@
+  7   8   func main() {                                   
+  …   …   @@ -9,5 +10,6 @@                                
+  9  10   }                                               
+ 10  11                                                   
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf08.golden 🔗

@@ -0,0 +1,5 @@
+  …   …   @@ -9,5 +10,6 @@                                
+  9  10   }                                               
+ 10  11                                                   
+ 11  12   func getContent() string {                      
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf09.golden 🔗

@@ -0,0 +1,5 @@
+  9  10   }                                               
+ 10  11                                                   
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf10.golden 🔗

@@ -0,0 +1,5 @@
+ 10  11                                                   
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
+     13 +     content := strings.ToUpper("Hello, World!") 
+  …   …   …                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf11.golden 🔗

@@ -0,0 +1,5 @@
+ 11  12   func getContent() string {                      
+ 12     -     return "Hello, world!"                      
+     13 +     content := strings.ToUpper("Hello, World!") 
+     14 +     return content                              
+ 13  15   }                                               

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf12.golden 🔗

@@ -0,0 +1,5 @@
+ 12     -     return "Hello, world!"                      
+     13 +     content := strings.ToUpper("Hello, World!") 
+     14 +     return content                              
+ 13  15   }                                               
+                                                          

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf13.golden 🔗

@@ -0,0 +1,5 @@
+     13 +     content := strings.ToUpper("Hello, World!") 
+     14 +     return content                              
+ 13  15   }                                               
+                                                          
+                                                          

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf14.golden 🔗

@@ -0,0 +1,5 @@
+     14 +     return content                              
+ 13  15   }                                               
+                                                          
+                                                          
+                                                          

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf15.golden 🔗

@@ -0,0 +1,5 @@
+ 13  15   }                                               
+                                                          
+                                                          
+                                                          
+                                                          

internal/exp/diffview/testdata/TestDiffViewYOffsetInfinite/Unified/YOffsetOf16.golden 🔗

@@ -0,0 +1,5 @@
+                                                          
+                                                          
+                                                          
+                                                          
+                                                          

internal/llm/agent/agent.go 🔗

@@ -50,6 +50,7 @@ type Service interface {
 	Model() models.Model
 	Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
 	Cancel(sessionID string)
+	CancelAll()
 	IsSessionBusy(sessionID string) bool
 	IsBusy() bool
 	Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
@@ -698,6 +699,13 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
 	return nil
 }
 
+func (a *agent) CancelAll() {
+	a.activeRequests.Range(func(key, value any) bool {
+		a.Cancel(key.(string)) // key is sessionID
+		return true
+	})
+}
+
 func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
 	cfg := config.Get()
 	agentConfig, ok := cfg.Agents[agentName]

internal/llm/prompt/coder.go 🔗

@@ -30,10 +30,6 @@ You are operating as and within the Crush CLI, a terminal-based agentic coding a
 You can:
 - Receive user prompts, project context, and files.
 - Stream responses and emit function calls (e.g., shell commands, code edits).
-- Apply patches, run commands, and manage user approvals based on policy.
-- Work inside a sandboxed, git-backed workspace with rollback support.
-- Log telemetry so sessions can be replayed or inspected later.
-- More details on your functionality are available at "crush --help"
 
 
 You 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:
 - If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
     - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
 - When your task involves writing or modifying files:
-    - 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.
+    - 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.
     - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
 - 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.
 - If you send a path not including the working dir, the working dir will be prepended to it.
 - Remember the user does not see the full output of tools
+- NEVER use emojis in your responses
 `
 
 const 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.
 
 IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
 
+
 # Memory
 If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
 1. 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
 
 <example>
 user: write tests for new feature
-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]
+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]
 </example>
 
 # Proactiveness
@@ -165,6 +163,8 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
 - If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
 - 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.
 
+VERY IMPORTANT NEVER use emojis in your responses.
+
 You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
 
 func getEnvironmentInfo() string {

internal/llm/tools/bash.go 🔗

@@ -36,6 +36,7 @@ const (
 	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
 	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
 	MaxOutputLength = 30000
+	BashNoOutput    = "no output"
 )
 
 var bannedCommands = []string{
@@ -321,7 +322,7 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
 		EndTime:   time.Now().UnixMilli(),
 	}
 	if stdout == "" {
-		return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
+		return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
 	}
 	return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
 }

internal/tui/components/chat/chat.go 🔗

@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
 				assistantIndex = i
 			}
 		} else if tc, ok := item.(messages.ToolCallCmp); ok {
-			if tc.ParentMessageId() == messageID {
+			if tc.ParentMessageID() == messageID {
 				toolCalls[i] = tc
 			}
 		}
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 			assistantIndex,
 			messages.NewMessageCmp(
 				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
+
+		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+			m.listCmp.AppendItem(
+				messages.NewAssistantSection(
+					msg,
+					time.Unix(m.lastUserMessageTime, 0),
+				),
+			)
+		}
 	} else if hasToolCallsOnly {
 		m.listCmp.DeleteItem(assistantIndex)
 	}
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 		cmd := m.listCmp.AppendItem(
 			messages.NewMessageCmp(
 				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
 		cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
 			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
 		case message.Assistant:
 			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
+			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+				uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
+			}
 		}
 	}
 
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 			uiMessages,
 			messages.NewMessageCmp(
 				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
 	}

internal/tui/components/chat/messages/messages.go 🔗

@@ -8,13 +8,14 @@ import (
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/llm/models"
 	"github.com/charmbracelet/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/llm/models"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -37,32 +38,17 @@ type messageCmp struct {
 	focused bool // Focus state for border styling
 
 	// Core message data and state
-	message             message.Message // The underlying message content
-	spinning            bool            // Whether to show loading animation
-	anim                util.Model      // Animation component for loading states
-	lastUserMessageTime time.Time       // Used for calculating response duration
-}
-
-// MessageOption provides functional options for configuring message components
-type MessageOption func(*messageCmp)
-
-// WithLastUserMessageTime sets the timestamp of the last user message
-// for calculating assistant response duration
-func WithLastUserMessageTime(t time.Time) MessageOption {
-	return func(m *messageCmp) {
-		m.lastUserMessageTime = t
-	}
+	message  message.Message // The underlying message content
+	spinning bool            // Whether to show loading animation
+	anim     util.Model      // Animation component for loading states
 }
 
 // NewMessageCmp creates a new message component with the given message and options
-func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
+func NewMessageCmp(msg message.Message) MessageCmp {
 	m := &messageCmp{
 		message: msg,
 		anim:    anim.New(15, ""),
 	}
-	for _, opt := range opts {
-		opt(m)
-	}
 	return m
 }
 
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
 // renderAssistantMessage renders assistant messages with optional footer information.
 // Shows model name, response time, and finish reason when the message is complete.
 func (m *messageCmp) renderAssistantMessage() string {
-	t := styles.CurrentTheme()
 	parts := []string{
 		m.markdownContent(),
 	}
 
-	finished := m.message.IsFinished()
-	finishData := m.message.FinishPart()
-	// Only show the footer if the message is not a tool call
-	if finished && finishData.Reason != message.FinishReasonToolUse {
-		infoMsg := ""
-		switch finishData.Reason {
-		case message.FinishReasonEndTurn:
-			finishTime := time.Unix(finishData.Time, 0)
-			duration := finishTime.Sub(m.lastUserMessageTime)
-			infoMsg = duration.String()
-		case message.FinishReasonCanceled:
-			infoMsg = "canceled"
-		case message.FinishReasonError:
-			infoMsg = "error"
-		case message.FinishReasonPermissionDenied:
-			infoMsg = "permission denied"
-		}
-		assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
-		parts = append(parts, core.Section(assistant, m.textWidth()))
-	}
-
 	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
 	return m.style().Render(joined)
 }
@@ -200,7 +164,7 @@ func (m *messageCmp) renderUserMessage() string {
 		parts = append(parts, "", strings.Join(attachments, ""))
 	}
 	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
-	return m.style().MarginBottom(1).Render(joined)
+	return m.style().Render(joined)
 }
 
 // toMarkdown converts text content to rendered markdown using the configured renderer
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
 		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
 			// Sometimes the LLMs respond with no content when they think the previous tool result
 			//  provides the requested question
-			content = "*Finished without output*"
+			content = ""
 		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
 			content = "*Canceled*"
 		}
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
 func (m *messageCmp) Spinning() bool {
 	return m.spinning
 }
+
+type AssistantSection interface {
+	util.Model
+	layout.Sizeable
+	list.SectionHeader
+}
+type assistantSectionModel struct {
+	width               int
+	message             message.Message
+	lastUserMessageTime time.Time
+}
+
+func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
+	return &assistantSectionModel{
+		width:               0,
+		message:             message,
+		lastUserMessageTime: lastUserMessageTime,
+	}
+}
+
+func (m *assistantSectionModel) Init() tea.Cmd {
+	return nil
+}
+
+func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *assistantSectionModel) View() tea.View {
+	t := styles.CurrentTheme()
+	finishData := m.message.FinishPart()
+	finishTime := time.Unix(finishData.Time, 0)
+	duration := finishTime.Sub(m.lastUserMessageTime)
+	infoMsg := t.S().Subtle.Render(duration.String())
+	icon := t.S().Subtle.Render(styles.ModelIcon)
+	model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
+	assistant := fmt.Sprintf("%s  %s %s", icon, model, infoMsg)
+	return tea.NewView(
+		t.S().Base.PaddingLeft(2).Render(
+			core.Section(assistant, m.width-2),
+		),
+	)
+}
+
+func (m *assistantSectionModel) GetSize() (int, int) {
+	return m.width, 1
+}
+
+func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}
+
+func (m *assistantSectionModel) IsSectionHeader() bool {
+	return true
+}

internal/tui/components/chat/messages/renderer.go 🔗

@@ -111,8 +111,18 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
 	return json.Unmarshal([]byte(input), target)
 }
 
+// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
+func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
+	t := styles.CurrentTheme()
+	tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
+	return tool + renderParamList(true, width-lipgloss.Width(tool), params...)
+}
+
 // makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
 func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
+	if v.isNested {
+		return br.makeNestedHeader(v, tool, width, params...)
+	}
 	t := styles.CurrentTheme()
 	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 	if v.result.ToolCallID != "" {
@@ -125,16 +135,17 @@ func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params
 		icon = t.S().Muted.Render(styles.ToolPending)
 	}
 	tool = t.S().Base.Foreground(t.Blue).Render(tool)
-	prefix := fmt.Sprintf("%s %s: ", icon, tool)
-	return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
+	prefix := fmt.Sprintf("%s %s ", icon, tool)
+	return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
 }
 
 // renderError provides consistent error rendering
 func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
 	t := styles.CurrentTheme()
 	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
-	message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
-	return joinHeaderBody(header, message)
+	errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
+	message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
+	return joinHeaderBody(header, errorTag+" "+message)
 }
 
 // Register tool renderers
@@ -188,6 +199,9 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
 	args := newParamBuilder().addMain(cmd).build()
 
 	return br.renderWithParams(v, "Bash", args, func() string {
+		if v.result.Content == tools.BashNoOutput {
+			return ""
+		}
 		return renderPlainContent(v, v.result.Content)
 	})
 }
@@ -244,13 +258,12 @@ type editRenderer struct {
 // Render displays the edited file with a formatted diff of changes
 func (er editRenderer) Render(v *toolCallCmp) string {
 	var params tools.EditParams
-	if err := er.unmarshalParams(v.call.Input, &params); err != nil {
-		return er.renderError(v, "Invalid edit parameters")
+	var args []string
+	if err := er.unmarshalParams(v.call.Input, &params); err == nil {
+		file := fsext.PrettyPath(params.FilePath)
+		args = newParamBuilder().addMain(file).build()
 	}
 
-	file := fsext.PrettyPath(params.FilePath)
-	args := newParamBuilder().addMain(file).build()
-
 	return er.renderWithParams(v, "Edit", args, func() string {
 		var meta tools.EditResponseMetadata
 		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
@@ -260,8 +273,10 @@ func (er editRenderer) Render(v *toolCallCmp) string {
 		formatter := core.DiffFormatter().
 			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
 			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
-			Split().
 			Width(v.textWidth() - 2) // -2 for padding
+		if v.textWidth() > 120 {
+			formatter = formatter.Split()
+		}
 		return formatter.String()
 	})
 }
@@ -278,13 +293,13 @@ type writeRenderer struct {
 // Render displays the file being written with syntax highlighting
 func (wr writeRenderer) Render(v *toolCallCmp) string {
 	var params tools.WriteParams
-	if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
-		return wr.renderError(v, "Invalid write parameters")
+	var args []string
+	var file string
+	if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
+		file = fsext.PrettyPath(params.FilePath)
+		args = newParamBuilder().addMain(file).build()
 	}
 
-	file := fsext.PrettyPath(params.FilePath)
-	args := newParamBuilder().addMain(file).build()
-
 	return wr.renderWithParams(v, "Write", args, func() string {
 		return renderCodeContent(v, file, params.Content, 0)
 	})
@@ -302,16 +317,15 @@ type fetchRenderer struct {
 // Render displays the fetched URL with format and timeout parameters
 func (fr fetchRenderer) Render(v *toolCallCmp) string {
 	var params tools.FetchParams
-	if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
-		return fr.renderError(v, "Invalid fetch parameters")
+	var args []string
+	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.URL).
+			addKeyValue("format", params.Format).
+			addKeyValue("timeout", formatTimeout(params.Timeout)).
+			build()
 	}
 
-	args := newParamBuilder().
-		addMain(params.URL).
-		addKeyValue("format", params.Format).
-		addKeyValue("timeout", formatTimeout(params.Timeout)).
-		build()
-
 	return fr.renderWithParams(v, "Fetch", args, func() string {
 		file := fr.getFileExtension(params.Format)
 		return renderCodeContent(v, file, v.result.Content, 0)
@@ -350,15 +364,14 @@ type globRenderer struct {
 // Render displays the glob pattern with optional path parameter
 func (gr globRenderer) Render(v *toolCallCmp) string {
 	var params tools.GlobParams
-	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
-		return gr.renderError(v, "Invalid glob parameters")
+	var args []string
+	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.Pattern).
+			addKeyValue("path", params.Path).
+			build()
 	}
 
-	args := newParamBuilder().
-		addMain(params.Pattern).
-		addKeyValue("path", params.Path).
-		build()
-
 	return gr.renderWithParams(v, "Glob", args, func() string {
 		return renderPlainContent(v, v.result.Content)
 	})
@@ -376,17 +389,16 @@ type grepRenderer struct {
 // Render displays the search pattern with path, include, and literal text options
 func (gr grepRenderer) Render(v *toolCallCmp) string {
 	var params tools.GrepParams
-	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
-		return gr.renderError(v, "Invalid grep parameters")
+	var args []string
+	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.Pattern).
+			addKeyValue("path", params.Path).
+			addKeyValue("include", params.Include).
+			addFlag("literal", params.LiteralText).
+			build()
 	}
 
-	args := newParamBuilder().
-		addMain(params.Pattern).
-		addKeyValue("path", params.Path).
-		addKeyValue("include", params.Include).
-		addFlag("literal", params.LiteralText).
-		build()
-
 	return gr.renderWithParams(v, "Grep", args, func() string {
 		return renderPlainContent(v, v.result.Content)
 	})
@@ -404,17 +416,16 @@ type lsRenderer struct {
 // Render displays the directory path, defaulting to current directory
 func (lr lsRenderer) Render(v *toolCallCmp) string {
 	var params tools.LSParams
-	if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
-		return lr.renderError(v, "Invalid ls parameters")
-	}
+	var args []string
+	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
+		path := params.Path
+		if path == "" {
+			path = "."
+		}
+		path = fsext.PrettyPath(path)
 
-	path := params.Path
-	if path == "" {
-		path = "."
+		args = newParamBuilder().addMain(path).build()
 	}
-	path = fsext.PrettyPath(path)
-
-	args := newParamBuilder().addMain(path).build()
 
 	return lr.renderWithParams(v, "List", args, func() string {
 		return renderPlainContent(v, v.result.Content)
@@ -433,16 +444,15 @@ type sourcegraphRenderer struct {
 // Render displays the search query with optional count and context window parameters
 func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 	var params tools.SourcegraphParams
-	if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
-		return sr.renderError(v, "Invalid sourcegraph parameters")
+	var args []string
+	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
+		args = newParamBuilder().
+			addMain(params.Query).
+			addKeyValue("count", formatNonZero(params.Count)).
+			addKeyValue("context", formatNonZero(params.ContextWindow)).
+			build()
 	}
 
-	args := newParamBuilder().
-		addMain(params.Query).
-		addKeyValue("count", formatNonZero(params.Count)).
-		addKeyValue("context", formatNonZero(params.ContextWindow)).
-		build()
-
 	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
 		return renderPlainContent(v, v.result.Content)
 	})
@@ -475,25 +485,47 @@ type agentRenderer struct {
 	baseRenderer
 }
 
+func RoundedEnumerator(children tree.Children, index int) string {
+	if children.Length()-1 == index {
+		return " ╰──"
+	}
+	return " ├──"
+}
+
 // Render displays agent task parameters and result content
 func (tr agentRenderer) Render(v *toolCallCmp) string {
+	t := styles.CurrentTheme()
 	var params agent.AgentParams
-	if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
-		return tr.renderError(v, "Invalid task parameters")
-	}
+	tr.unmarshalParams(v.call.Input, &params)
+
 	prompt := params.Prompt
 	prompt = strings.ReplaceAll(prompt, "\n", " ")
-	args := newParamBuilder().addMain(prompt).build()
 
-	header := tr.makeHeader(v, "Task", v.textWidth(), args...)
-	t := tree.Root(header)
+	header := tr.makeHeader(v, "Agent", v.textWidth())
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
+	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 // -2 for padding
+	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			taskTag,
+			" ",
+			prompt,
+		),
+	)
+	childTools := tree.Root(header)
 
 	for _, call := range v.nestedToolCalls {
-		t.Child(call.View())
+		childTools.Child(call.View())
 	}
-
 	parts := []string{
-		t.Enumerator(tree.RoundedEnumerator).String(),
+		childTools.Enumerator(RoundedEnumerator).String(),
 	}
 	if v.result.ToolCallID == "" {
 		v.spinning = true
@@ -516,7 +548,8 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
 }
 
 // renderParamList renders params, params[0] (params[1]=params[2] ....)
-func renderParamList(paramsWidth int, params ...string) string {
+func renderParamList(nested bool, paramsWidth int, params ...string) string {
+	t := styles.CurrentTheme()
 	if len(params) == 0 {
 		return ""
 	}
@@ -526,7 +559,10 @@ func renderParamList(paramsWidth int, params ...string) string {
 	}
 
 	if len(params) == 1 {
-		return mainParam
+		if nested {
+			return t.S().Muted.Render(mainParam)
+		}
+		return t.S().Subtle.Render(mainParam)
 	}
 	otherParams := params[1:]
 	// create pairs of key/value
@@ -547,15 +583,21 @@ func renderParamList(paramsWidth int, params ...string) string {
 	partsRendered := strings.Join(parts, ", ")
 	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
 	if remainingWidth < 30 {
+		if nested {
+			return t.S().Muted.Render(mainParam)
+		}
 		// No space for the params, just show the main
-		return mainParam
+		return t.S().Subtle.Render(mainParam)
 	}
 
 	if len(parts) > 0 {
 		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
 	}
 
-	return ansi.Truncate(mainParam, paramsWidth, "...")
+	if nested {
+		return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
+	}
+	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
 }
 
 // earlyState returns immediately‑rendered error/cancelled/ongoing states.
@@ -566,21 +608,21 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
 	case v.result.IsError:
 		message = v.renderToolError()
 	case v.cancelled:
-		message = "Cancelled"
+		message = t.S().Base.Padding(0, 1).Background(t.Border).Render("Cancelled")
 	case v.result.ToolCallID == "":
-		message = "Waiting for tool to start..."
+		message = t.S().Base.Padding(0, 1).Background(t.Accent).Foreground(t.FgSubtle).Render("Waiting for tool to start...")
 	default:
 		return "", false
 	}
 
 	message = t.S().Base.PaddingLeft(2).Render(message)
-	return lipgloss.JoinVertical(lipgloss.Left, header, message), true
+	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
 }
 
 func joinHeaderBody(header, body string) string {
 	t := styles.CurrentTheme()
 	body = t.S().Base.PaddingLeft(2).Render(body)
-	return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
+	return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
 }
 
 func renderPlainContent(v *toolCallCmp, content string) string {
@@ -600,46 +642,56 @@ func renderPlainContent(v *toolCallCmp, content string) string {
 		}
 		out = append(out, t.S().Muted.
 			Width(width).
-			Background(t.BgSubtle).
+			Background(t.BgBaseLighter).
 			Render(ln))
 	}
 
 	if len(lines) > responseContextHeight {
 		out = append(out, t.S().Muted.
-			Background(t.BgSubtle).
+			Background(t.BgBaseLighter).
 			Width(width).
 			Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
 	}
 	return strings.Join(out, "\n")
 }
 
+func pad(v any, width int) string {
+	s := fmt.Sprintf("%v", v)
+	w := ansi.StringWidth(s)
+	if w >= width {
+		return s
+	}
+	return strings.Repeat(" ", width-w) + s
+}
+
 func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
 	t := styles.CurrentTheme()
 	truncated := truncateHeight(content, responseContextHeight)
 
-	highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
+	highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
 	lines := strings.Split(highlighted, "\n")
 
 	if len(strings.Split(content, "\n")) > responseContextHeight {
 		lines = append(lines, t.S().Muted.
-			Background(t.BgSubtle).
-			Width(v.textWidth()-2).
-			Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
+			Background(t.BgBase).
+			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
 	}
 
+	maxLineNumber := len(lines) + offset
+	padding := lipgloss.Width(fmt.Sprintf("%d", maxLineNumber))
 	for i, ln := range lines {
-		num := t.S().Muted.
-			Background(t.BgSubtle).
-			PaddingLeft(4).
-			PaddingRight(2).
-			Render(fmt.Sprintf("%d", i+1+offset))
-		w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
+		num := t.S().Base.
+			Foreground(t.FgMuted).
+			Background(t.BgBase).
+			PaddingRight(1).
+			PaddingLeft(1).
+			Render(pad(i+1+offset, padding))
+		w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding
 		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
 			num,
 			t.S().Base.
-				Width(w).
-				Background(t.BgSubtle).
-				Render(v.fit(ln, w)))
+				PaddingLeft(1).
+				Render(v.fit(ln, w-1)))
 	}
 	return lipgloss.JoinVertical(lipgloss.Left, lines...)
 }
@@ -647,8 +699,9 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
 func (v *toolCallCmp) renderToolError() string {
 	t := styles.CurrentTheme()
 	err := strings.ReplaceAll(v.result.Content, "\n", " ")
-	err = fmt.Sprintf("Error: %s", err)
-	return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
+	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
+	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
+	return err
 }
 
 func truncateHeight(s string, h int) string {
@@ -662,7 +715,7 @@ func truncateHeight(s string, h int) string {
 func prettifyToolName(name string) string {
 	switch name {
 	case agent.AgentToolName:
-		return "Task"
+		return "Agent"
 	case tools.BashToolName:
 		return "Bash"
 	case tools.EditToolName:

internal/tui/components/chat/messages/tool.go 🔗

@@ -25,7 +25,7 @@ type ToolCallCmp interface {
 	SetToolResult(message.ToolResult)  // Update tool result
 	SetToolCall(message.ToolCall)      // Update tool call
 	SetCancelled()                     // Mark as cancelled
-	ParentMessageId() string           // Get parent message ID
+	ParentMessageID() string           // Get parent message ID
 	Spinning() bool                    // Animation state for pending tools
 	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
 	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
@@ -83,10 +83,10 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
 
 // NewToolCallCmp creates a new tool call component with the given parent message ID,
 // tool call, and optional configuration
-func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
 	m := &toolCallCmp{
 		call:            tc,
-		parentMessageID: parentMessageId,
+		parentMessageID: parentMessageID,
 	}
 	for _, opt := range opts {
 		opt(m)
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
 	box := m.style()
 
 	if !m.call.Finished && !m.cancelled {
-		if m.isNested {
-			return tea.NewView(box.Render(m.renderPending()))
-		}
 		return tea.NewView(box.Render(m.renderPending()))
 	}
 
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
 	}
 }
 
-// ParentMessageId returns the ID of the message that initiated this tool call
-func (m *toolCallCmp) ParentMessageId() string {
+// ParentMessageID returns the ID of the message that initiated this tool call
+func (m *toolCallCmp) ParentMessageID() string {
 	return m.parentMessageID
 }
 
@@ -210,9 +207,13 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
 // renderPending displays the tool name with a loading animation for pending tool calls
 func (m *toolCallCmp) renderPending() string {
 	t := styles.CurrentTheme()
+	if m.isNested {
+		tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
+		return fmt.Sprintf("%s %s", tool, m.anim.View())
+	}
 	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
-	return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
+	return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
 }
 
 // style returns the lipgloss style for the tool call component.
@@ -229,14 +230,17 @@ func (m *toolCallCmp) style() lipgloss.Style {
 // textWidth calculates the available width for text content,
 // accounting for borders and padding
 func (m *toolCallCmp) textWidth() int {
+	if m.isNested {
+		return m.width - 6
+	}
 	return m.width - 5 // take into account the border and PaddingLeft
 }
 
 // fit truncates content to fit within the specified width with ellipsis
 func (m *toolCallCmp) fit(content string, width int) string {
 	t := styles.CurrentTheme()
-	lineStyle := t.S().Muted.Background(t.BgSubtle)
-	dots := lineStyle.Render("...")
+	lineStyle := t.S().Muted
+	dots := lineStyle.Render("…")
 	return ansi.Truncate(content, width, dots)
 }
 

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -288,9 +288,9 @@ func (m *sidebarCmp) filesBlock() string {
 	})
 
 	for _, file := range files {
-		// Extract just the filename from the path
-
-		// Create status indicators for additions/deletions
+		if file.Additions == 0 && file.Deletions == 0 {
+			continue // skip files with no changes
+		}
 		var statusParts []string
 		if file.Additions > 0 {
 			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))

internal/tui/components/core/helpers.go 🔗

@@ -148,8 +148,9 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
 }
 
 func DiffFormatter() *diffview.DiffView {
+	t := styles.CurrentTheme()
 	formatDiff := diffview.New()
 	style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
-	diff := formatDiff.ChromaStyle(style)
+	diff := formatDiff.ChromaStyle(style).Style(t.S().Diff)
 	return diff
 }

internal/tui/components/core/list/keys.go 🔗

@@ -32,10 +32,10 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("k"),
 		),
 		UpOneItem: key.NewBinding(
-			key.WithKeys("shift+up", "shift+k"),
+			key.WithKeys("shift+up", "K"),
 		),
 		DownOneItem: key.NewBinding(
-			key.WithKeys("shift+down", "shift+j"),
+			key.WithKeys("shift+down", "J"),
 		),
 		HalfPageDown: key.NewBinding(
 			key.WithKeys("d"),
@@ -47,7 +47,7 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("g", "home"),
 		),
 		End: key.NewBinding(
-			key.WithKeys("shift+g", "end"),
+			key.WithKeys("G", "end"),
 		),
 	}
 }

internal/tui/components/core/list/list.go 🔗

@@ -749,8 +749,8 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
 func (m *model) goToBottom() tea.Cmd {
 	cmds := []tea.Cmd{m.blurSelected()}
 	m.viewState.reverse = true
+	m.selectionState.selectedIndex = m.findLastSelectableItem()
 	if m.isFocused {
-		m.selectionState.selectedIndex = m.findLastSelectableItem()
 		cmds = append(cmds, m.focusSelected())
 	}
 	m.ResetView()
@@ -764,7 +764,9 @@ func (m *model) goToTop() tea.Cmd {
 	cmds := []tea.Cmd{m.blurSelected()}
 	m.viewState.reverse = false
 	m.selectionState.selectedIndex = m.findFirstSelectableItem()
-	cmds = append(cmds, m.focusSelected())
+	if m.isFocused {
+		cmds = append(cmds, m.focusSelected())
+	}
 	m.ResetView()
 	return tea.Batch(cmds...)
 }

internal/tui/components/core/status/status.go 🔗

@@ -1,6 +1,7 @@
 package status
 
 import (
+	"strings"
 	"time"
 
 	"github.com/charmbracelet/bubbles/v2/help"
@@ -10,6 +11,8 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
 )
 
 type StatusCmp interface {
@@ -85,6 +88,7 @@ func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					TTL:  msg.Payload.PersistTime,
 				}
 			}
+			return m, m.clearMessageCmd(m.info.TTL)
 		}
 	}
 	return m, nil
@@ -94,18 +98,32 @@ func (m *statusCmp) View() tea.View {
 	t := styles.CurrentTheme()
 	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
 	if m.info.Msg != "" {
-		switch m.info.Type {
-		case util.InfoTypeError:
-			status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
-		case util.InfoTypeWarn:
-			status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
-		default:
-			status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
-		}
+		status = m.infoMsg()
 	}
 	return tea.NewView(status)
 }
 
+func (m *statusCmp) infoMsg() string {
+	t := styles.CurrentTheme()
+	message := ""
+	infoType := ""
+	switch m.info.Type {
+	case util.InfoTypeError:
+		infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
+		width := m.width - lipgloss.Width(infoType)
+		message = t.S().Base.Background(t.Error).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
+	case util.InfoTypeWarn:
+		infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
+		width := m.width - lipgloss.Width(infoType)
+		message = t.S().Base.Foreground(t.BgOverlay).Background(t.Warning).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
+	default:
+		infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
+		width := m.width - lipgloss.Width(infoType)
+		message = t.S().Base.Background(t.Success).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
+	}
+	return strings.Join([]string{infoType, message}, "")
+}
+
 func (m *statusCmp) ToggleFullHelp() {
 	m.help.ShowAll = !m.help.ShowAll
 }
@@ -119,7 +137,7 @@ func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
 	help := help.New()
 	help.Styles = t.S().Help
 	return &statusCmp{
-		messageTTL: 10 * time.Second,
+		messageTTL: 5 * time.Second,
 		help:       help,
 		keyMap:     keyMap,
 	}

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -211,15 +211,6 @@ func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
 	return cursor
 }
 
-func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
-	t := styles.CurrentTheme()
-	return t.S().Base.
-		Width(c.width).
-		Padding(1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus)
-}
-
 func (c *commandArgumentsDialogCmp) Position() (int, int) {
 	row := c.wHeight / 2
 	row -= c.wHeight / 2

internal/tui/components/dialogs/models/models.go 🔗

@@ -193,15 +193,15 @@ func (m *modelDialogCmp) listHeight() int {
 
 func GetSelectedModel(cfg *config.Config) models.Model {
 	agentCfg := cfg.Agents[config.AgentCoder]
-	selectedModelId := agentCfg.Model
-	return models.SupportedModels[selectedModelId]
+	selectedModelID := agentCfg.Model
+	return models.SupportedModels[selectedModelID]
 }
 
 func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
 	var providers []models.ModelProvider
-	for providerId, provider := range cfg.Providers {
+	for providerID, provider := range cfg.Providers {
 		if !provider.Disabled {
-			providers = append(providers, providerId)
+			providers = append(providers, providerID)
 		}
 	}
 

internal/tui/components/dialogs/permissions/keys.go 🔗

@@ -11,7 +11,12 @@ type KeyMap struct {
 	Select,
 	Allow,
 	AllowSession,
-	Deny key.Binding
+	Deny,
+	ToggleDiffMode,
+	ScrollDown,
+	ScrollUp key.Binding
+	ScrollLeft,
+	ScrollRight key.Binding
 }
 
 func DefaultKeyMap() KeyMap {
@@ -41,9 +46,29 @@ func DefaultKeyMap() KeyMap {
 			key.WithHelp("d", "deny"),
 		),
 		Select: key.NewBinding(
-			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithKeys("enter", "ctrl+y"),
 			key.WithHelp("enter", "confirm"),
 		),
+		ToggleDiffMode: key.NewBinding(
+			key.WithKeys("t"),
+			key.WithHelp("t", "toggle diff mode"),
+		),
+		ScrollDown: key.NewBinding(
+			key.WithKeys("shift+down", "J"),
+			key.WithHelp("shift+↓", "scroll down"),
+		),
+		ScrollUp: key.NewBinding(
+			key.WithKeys("shift+up", "K"),
+			key.WithHelp("shift+↑", "scroll up"),
+		),
+		ScrollLeft: key.NewBinding(
+			key.WithKeys("shift+left", "H"),
+			key.WithHelp("shift+←", "scroll left"),
+		),
+		ScrollRight: key.NewBinding(
+			key.WithKeys("shift+right", "L"),
+			key.WithHelp("shift+→", "scroll right"),
+		),
 	}
 }
 
@@ -57,6 +82,11 @@ func (k KeyMap) KeyBindings() []key.Binding {
 		k.Allow,
 		k.AllowSession,
 		k.Deny,
+		k.ToggleDiffMode,
+		k.ScrollDown,
+		k.ScrollUp,
+		k.ScrollLeft,
+		k.ScrollRight,
 	}
 }
 
@@ -74,9 +104,14 @@ func (k KeyMap) FullHelp() [][]key.Binding {
 // ShortHelp implements help.KeyMap.
 func (k KeyMap) ShortHelp() []key.Binding {
 	return []key.Binding{
-		k.Allow,
-		k.AllowSession,
-		k.Deny,
-		k.Select,
+		k.ToggleDiffMode,
+		key.NewBinding(
+			key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
+			key.WithHelp("shift+←↓↑→", "scroll"),
+		),
+		key.NewBinding(
+			key.WithKeys("shift+h", "shift+j", "shift+k", "shift+l"),
+			key.WithHelp("shift+hjkl", "scroll"),
+		),
 	}
 }

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/viewport"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -50,6 +51,11 @@ type permissionDialogCmp struct {
 	contentViewPort viewport.Model
 	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
 
+	// Diff view state
+	diffSplitMode bool // true for split, false for unified
+	diffXOffset   int  // horizontal scroll offset
+	diffYOffset   int  // vertical scroll offset
+
 	keyMap KeyMap
 }
 
@@ -101,6 +107,21 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
 				util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
 			)
+		case key.Matches(msg, p.keyMap.ToggleDiffMode):
+			p.diffSplitMode = !p.diffSplitMode
+			return p, nil
+		case key.Matches(msg, p.keyMap.ScrollDown):
+			p.diffYOffset += 1
+			return p, nil
+		case key.Matches(msg, p.keyMap.ScrollUp):
+			p.diffYOffset = max(0, p.diffYOffset-1)
+			return p, nil
+		case key.Matches(msg, p.keyMap.ScrollLeft):
+			p.diffXOffset = max(0, p.diffXOffset-5)
+			return p, nil
+		case key.Matches(msg, p.keyMap.ScrollRight):
+			p.diffXOffset += 5
+			return p, nil
 		default:
 			// Pass other keys to viewport
 			viewPort, cmd := p.contentViewPort.Update(msg)
@@ -269,8 +290,15 @@ func (p *permissionDialogCmp) renderEditContent() string {
 		formatter := core.DiffFormatter().
 			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
 			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
+			Height(p.contentViewPort.Height()).
 			Width(p.contentViewPort.Width()).
-			Split()
+			XOffset(p.diffXOffset).
+			YOffset(p.diffYOffset)
+		if p.diffSplitMode {
+			formatter = formatter.Split()
+		} else {
+			formatter = formatter.Unified()
+		}
 
 		diff := formatter.String()
 		contentHeight := min(p.height-9, lipgloss.Height(diff))
@@ -367,11 +395,13 @@ func (p *permissionDialogCmp) render() string {
 
 	// Render content based on tool type
 	var contentFinal string
+	var contentHelp string
 	switch p.permission.ToolName {
 	case tools.BashToolName:
 		contentFinal = p.renderBashContent()
 	case tools.EditToolName:
 		contentFinal = p.renderEditContent()
+		contentHelp = help.New().View(p.keyMap)
 	case tools.WriteToolName:
 		contentFinal = p.renderWriteContent()
 	case tools.FetchToolName:
@@ -381,8 +411,7 @@ func (p *permissionDialogCmp) render() string {
 	}
 	// Calculate content height dynamically based on window size
 
-	content := lipgloss.JoinVertical(
-		lipgloss.Top,
+	strs := []string{
 		title,
 		"",
 		headerContent,
@@ -390,7 +419,11 @@ func (p *permissionDialogCmp) render() string {
 		"",
 		buttons,
 		"",
-	)
+	}
+	if contentHelp != "" {
+		strs = append(strs, "", contentHelp)
+	}
+	content := lipgloss.JoinVertical(lipgloss.Top, strs...)
 
 	return baseStyle.
 		Padding(0, 1).

internal/tui/keys.go 🔗

@@ -61,8 +61,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
 		}
 	}
 
-	for i := 0; i < len(cleaned); i += 2 {
-		end := min(i+2, len(cleaned))
+	for i := 0; i < len(cleaned); i += 3 {
+		end := min(i+3, len(cleaned))
 		m = append(m, cleaned[i:end])
 	}
 	return m

internal/tui/page/chat/chat.go 🔗

@@ -3,6 +3,7 @@ package chat
 import (
 	"context"
 	"strings"
+	"time"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,6 +34,7 @@ type (
 	ChatFocusedMsg    struct {
 		Focused bool // True if the chat input is focused, false otherwise
 	}
+	CancelTimerExpiredMsg struct{}
 )
 
 type ChatPage interface {
@@ -57,6 +59,8 @@ type chatPage struct {
 	showDetails      bool // Show details in the header
 	header           header.Header
 	compactSidebar   layout.Container
+
+	cancelPending bool // True if ESC was pressed once and waiting for second press
 }
 
 func (p *chatPage) Init() tea.Cmd {
@@ -67,9 +71,19 @@ func (p *chatPage) Init() tea.Cmd {
 	)
 }
 
+// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
+func (p *chatPage) cancelTimerCmd() tea.Cmd {
+	return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
+		return CancelTimerExpiredMsg{}
+	})
+}
+
 func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
+	case CancelTimerExpiredMsg:
+		p.cancelPending = false
+		return p, nil
 	case tea.WindowSizeMsg:
 		h, cmd := p.header.Update(msg)
 		cmds = append(cmds, cmd)
@@ -181,10 +195,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return p, tea.Batch(cmds...)
 		case key.Matches(msg, p.keyMap.Cancel):
 			if p.session.ID != "" {
-				// Cancel the current session's generation process
-				// This allows users to interrupt long-running operations
-				p.app.CoderAgent.Cancel(p.session.ID)
-				return p, nil
+				if p.cancelPending {
+					// Second ESC press - actually cancel the session
+					p.cancelPending = false
+					p.app.CoderAgent.Cancel(p.session.ID)
+					return p, nil
+				} else {
+					// First ESC press - start the timer
+					p.cancelPending = true
+					return p, p.cancelTimerCmd()
+				}
 			}
 		case key.Matches(msg, p.keyMap.Details):
 			if p.session.ID == "" || !p.compactMode {
@@ -336,7 +356,14 @@ func (p *chatPage) Bindings() []key.Binding {
 		p.keyMap.AddAttachment,
 	}
 	if p.app.CoderAgent.IsBusy() {
-		bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
+		cancelBinding := p.keyMap.Cancel
+		if p.cancelPending {
+			cancelBinding = key.NewBinding(
+				key.WithKeys("esc"),
+				key.WithHelp("esc", "press again to cancel"),
+			)
+		}
+		bindings = append([]key.Binding{cancelBinding}, bindings...)
 	}
 
 	if p.chatFocused {

internal/tui/styles/crush.go 🔗

@@ -14,9 +14,10 @@ func NewCrushTheme() *Theme {
 		Tertiary:  charmtone.Bok,
 		Accent:    charmtone.Zest,
 		// Backgrounds
-		BgBase:    charmtone.Pepper,
-		BgSubtle:  charmtone.Charcoal,
-		BgOverlay: charmtone.Iron,
+		BgBase:        charmtone.Pepper,
+		BgBaseLighter: Lighten(charmtone.Pepper, 2),
+		BgSubtle:      charmtone.Charcoal,
+		BgOverlay:     charmtone.Iron,
 
 		// Foregrounds
 		FgBase:      charmtone.Ash,
@@ -38,7 +39,10 @@ func NewCrushTheme() *Theme {
 		// Colors
 		White: charmtone.Butter,
 
-		Blue: charmtone.Malibu,
+		BlueLight: charmtone.Sardine,
+		Blue:      charmtone.Malibu,
+
+		Yellow: charmtone.Mustard,
 
 		Green:      charmtone.Julep,
 		GreenDark:  charmtone.Guac,

internal/tui/styles/theme.go 🔗

@@ -10,6 +10,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/textarea"
 	"github.com/charmbracelet/bubbles/v2/textinput"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/exp/diffview"
 	"github.com/charmbracelet/glamour/v2/ansi"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/lucasb-eyer/go-colorful"
@@ -31,9 +32,10 @@ type Theme struct {
 	Tertiary  color.Color
 	Accent    color.Color
 
-	BgBase    color.Color
-	BgSubtle  color.Color
-	BgOverlay color.Color
+	BgBase        color.Color
+	BgBaseLighter color.Color
+	BgSubtle      color.Color
+	BgOverlay     color.Color
 
 	FgBase      color.Color
 	FgMuted     color.Color
@@ -52,8 +54,13 @@ type Theme struct {
 	// Colors
 	// White
 	White color.Color
+
 	// Blues
-	Blue color.Color
+	BlueLight color.Color
+	Blue      color.Color
+
+	// Yellows
+	Yellow color.Color
 
 	// Greens
 	Green      color.Color
@@ -65,26 +72,9 @@ type Theme struct {
 	RedDark  color.Color
 	RedLight color.Color
 
-	// TODO: add any others needed
-
 	styles *Styles
 }
 
-type Diff struct {
-	Added               color.Color
-	Removed             color.Color
-	Context             color.Color
-	HunkHeader          color.Color
-	HighlightAdded      color.Color
-	HighlightRemoved    color.Color
-	AddedBg             color.Color
-	RemovedBg           color.Color
-	ContextBg           color.Color
-	LineNumber          color.Color
-	AddedLineNumberBg   color.Color
-	RemovedLineNumberBg color.Color
-}
-
 type Styles struct {
 	Base         lipgloss.Style
 	SelectedBase lipgloss.Style
@@ -112,7 +102,7 @@ type Styles struct {
 	Help help.Styles
 
 	// Diff
-	Diff Diff
+	Diff diffview.Style
 
 	// FilePicker
 	FilePicker filepicker.Styles
@@ -421,22 +411,50 @@ func (t *Theme) buildStyles() *Styles {
 			FullSeparator:  base.Foreground(t.Border),
 		},
 
-		// TODO: Fix this this is bad
-		Diff: Diff{
-			Added:               t.Green,
-			Removed:             t.Red,
-			Context:             t.FgSubtle,
-			HunkHeader:          t.FgSubtle,
-			HighlightAdded:      t.GreenLight,
-			HighlightRemoved:    t.RedLight,
-			AddedBg:             t.GreenDark,
-			RemovedBg:           t.RedDark,
-			ContextBg:           t.BgSubtle,
-			LineNumber:          t.FgMuted,
-			AddedLineNumberBg:   t.GreenDark,
-			RemovedLineNumberBg: t.RedDark,
+		Diff: diffview.Style{
+			DividerLine: diffview.LineStyle{
+				LineNumber: lipgloss.NewStyle().
+					Foreground(t.FgHalfMuted).
+					Background(t.BgBaseLighter),
+				Code: lipgloss.NewStyle().
+					Foreground(t.FgHalfMuted).
+					Background(t.BgBaseLighter),
+			},
+			MissingLine: diffview.LineStyle{
+				LineNumber: lipgloss.NewStyle().
+					Background(t.BgBaseLighter),
+				Code: lipgloss.NewStyle().
+					Background(t.BgBaseLighter),
+			},
+			EqualLine: diffview.LineStyle{
+				LineNumber: lipgloss.NewStyle().
+					Foreground(t.FgMuted).
+					Background(t.BgBase),
+				Code: lipgloss.NewStyle().
+					Foreground(t.FgMuted).
+					Background(t.BgBase),
+			},
+			InsertLine: diffview.LineStyle{
+				LineNumber: lipgloss.NewStyle().
+					Foreground(lipgloss.Color("#629657")).
+					Background(lipgloss.Color("#2b322a")),
+				Symbol: lipgloss.NewStyle().
+					Foreground(lipgloss.Color("#629657")).
+					Background(lipgloss.Color("#323931")),
+				Code: lipgloss.NewStyle().
+					Background(lipgloss.Color("#323931")),
+			},
+			DeleteLine: diffview.LineStyle{
+				LineNumber: lipgloss.NewStyle().
+					Foreground(lipgloss.Color("#a45c59")).
+					Background(lipgloss.Color("#312929")),
+				Symbol: lipgloss.NewStyle().
+					Foreground(lipgloss.Color("#a45c59")).
+					Background(lipgloss.Color("#383030")),
+				Code: lipgloss.NewStyle().
+					Background(lipgloss.Color("#383030")),
+			},
 		},
-
 		FilePicker: filepicker.Styles{
 			DisabledCursor:   base.Foreground(t.FgMuted),
 			Cursor:           base.Foreground(t.FgBase),

internal/tui/tui.go 🔗

@@ -94,12 +94,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:
-		logging.Info(
-			"Keyboard enhancements detected",
-			"Disambiguation", msg.SupportsKeyDisambiguation(),
-			"ReleaseKeys", msg.SupportsKeyReleases(),
-			"UniformKeys", msg.SupportsUniformKeyLayout(),
-		)
 		return a, nil
 	case tea.WindowSizeMsg:
 		return a, a.handleWindowResize(msg.Width, msg.Height)
@@ -260,7 +254,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 	var cmds []tea.Cmd
 	a.wWidth, a.wHeight = width, height
 	if a.showingFullHelp {
-		height -= 3
+		height -= 4
 	} else {
 		height -= 2
 	}

todos.md 🔗

@@ -20,7 +20,16 @@
 - [ ] Parallel tool calls and permissions
   - [ ] Run the tools in parallel and add results in parallel
   - [ ] Show multiple permissions dialogs
+- [ ] Add another space around buttons
+- [ ] Completions
+  - [ ] Should change the help to show the completions stuff
+  - [ ] Should make it wider
+  - [ ] Tab and ctrl+y should accept
+  - [ ] Words should line up
+  - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
 - [ ] Investigate messages issues
+  - [ ] Make the agent separator look like the
+  - [ ] Cleanup tool calls (watch all states)
   - [ ] Weird behavior sometimes the message does not update
   - [ ] Message length (I saw the message go beyond the correct length when there are errors)
   - [ ] Address UX issues