on main help sign in
initial chat refactor

wip

commitb364394cfeafea7fb03aaf9ee52653d53538c0b6 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 20 May 2025, 14:28 (1 year ago) parentaf838d1
13 files changed+1667−315
internal/diff/diff.go+9−237 internal/highlight/highlight.go+227 internal/tui/components/chat/chat.go−1 internal/tui/components/chat/list_v2.go+120 internal/tui/components/chat/message.go−69 internal/tui/components/chat/message_v2.go+244 internal/tui/components/chat/sidebar.go+1−1 internal/tui/components/chat/tool_message.go+365 internal/tui/components/core/list/keys.go+70 internal/tui/components/core/list/list.go+625 internal/tui/page/chat.go+1−1 internal/tui/styles/markdown.go+1−3 internal/tui/theme/opencode.go+4−3
internal/diff/diff.go+9−237
@@ -1,22 +1,17 @@
11package diff
22
33import (
4− "bytes"
54 "fmt"
65 "image/color"
7− "io"
86 "regexp"
97 "strconv"
108 "strings"
119
12− "github.com/alecthomas/chroma/v2"
13− "github.com/alecthomas/chroma/v2/formatters"
14− "github.com/alecthomas/chroma/v2/lexers"
15− "github.com/alecthomas/chroma/v2/styles"
1610 "github.com/aymanbagabas/go-udiff"
1711 "github.com/charmbracelet/lipgloss/v2"
1812 "github.com/charmbracelet/x/ansi"
1913 "github.com/opencode-ai/opencode/internal/config"
14+ "github.com/opencode-ai/opencode/internal/highlight"
2015 "github.com/opencode-ai/opencode/internal/tui/theme"
2116 "github.com/sergi/go-diff/diffmatchpatch"
2217)
@@ -322,216 +317,6 @@ func pairLines(lines []DiffLine) []linePair {
322317// -------------------------------------------------------------------------
323318// Syntax Highlighting
324319// -------------------------------------------------------------------------
325−
326−// SyntaxHighlight applies syntax highlighting to text based on file extension
327−func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
328− t := theme.CurrentTheme()
329−
330− // Determine the language lexer to use
331− l := lexers.Match(fileName)
332− if l == nil {
333− l = lexers.Analyse(source)
334− }
335− if l == nil {
336− l = lexers.Fallback
337− }
338− l = chroma.Coalesce(l)
339−
340− // Get the formatter
341− f := formatters.Get(formatter)
342− if f == nil {
343− f = formatters.Fallback
344− }
345−
346− // Dynamic theme based on current theme values
347− syntaxThemeXml := fmt.Sprintf(`
348− <style name="opencode-theme">
349− <!-- Base colors -->
350− <entry type="Background" style="bg:%s"/>
351− <entry type="Text" style="%s"/>
352− <entry type="Other" style="%s"/>
353− <entry type="Error" style="%s"/>
354− <!-- Keywords -->
355− <entry type="Keyword" style="%s"/>
356− <entry type="KeywordConstant" style="%s"/>
357− <entry type="KeywordDeclaration" style="%s"/>
358− <entry type="KeywordNamespace" style="%s"/>
359− <entry type="KeywordPseudo" style="%s"/>
360− <entry type="KeywordReserved" style="%s"/>
361− <entry type="KeywordType" style="%s"/>
362− <!-- Names -->
363− <entry type="Name" style="%s"/>
364− <entry type="NameAttribute" style="%s"/>
365− <entry type="NameBuiltin" style="%s"/>
366− <entry type="NameBuiltinPseudo" style="%s"/>
367− <entry type="NameClass" style="%s"/>
368− <entry type="NameConstant" style="%s"/>
369− <entry type="NameDecorator" style="%s"/>
370− <entry type="NameEntity" style="%s"/>
371− <entry type="NameException" style="%s"/>
372− <entry type="NameFunction" style="%s"/>
373− <entry type="NameLabel" style="%s"/>
374− <entry type="NameNamespace" style="%s"/>
375− <entry type="NameOther" style="%s"/>
376− <entry type="NameTag" style="%s"/>
377− <entry type="NameVariable" style="%s"/>
378− <entry type="NameVariableClass" style="%s"/>
379− <entry type="NameVariableGlobal" style="%s"/>
380− <entry type="NameVariableInstance" style="%s"/>
381− <!-- Literals -->
382− <entry type="Literal" style="%s"/>
383− <entry type="LiteralDate" style="%s"/>
384− <entry type="LiteralString" style="%s"/>
385− <entry type="LiteralStringBacktick" style="%s"/>
386− <entry type="LiteralStringChar" style="%s"/>
387− <entry type="LiteralStringDoc" style="%s"/>
388− <entry type="LiteralStringDouble" style="%s"/>
389− <entry type="LiteralStringEscape" style="%s"/>
390− <entry type="LiteralStringHeredoc" style="%s"/>
391− <entry type="LiteralStringInterpol" style="%s"/>
392− <entry type="LiteralStringOther" style="%s"/>
393− <entry type="LiteralStringRegex" style="%s"/>
394− <entry type="LiteralStringSingle" style="%s"/>
395− <entry type="LiteralStringSymbol" style="%s"/>
396− <!-- Numbers -->
397− <entry type="LiteralNumber" style="%s"/>
398− <entry type="LiteralNumberBin" style="%s"/>
399− <entry type="LiteralNumberFloat" style="%s"/>
400− <entry type="LiteralNumberHex" style="%s"/>
401− <entry type="LiteralNumberInteger" style="%s"/>
402− <entry type="LiteralNumberIntegerLong" style="%s"/>
403− <entry type="LiteralNumberOct" style="%s"/>
404− <!-- Operators -->
405− <entry type="Operator" style="%s"/>
406− <entry type="OperatorWord" style="%s"/>
407− <entry type="Punctuation" style="%s"/>
408− <!-- Comments -->
409− <entry type="Comment" style="%s"/>
410− <entry type="CommentHashbang" style="%s"/>
411− <entry type="CommentMultiline" style="%s"/>
412− <entry type="CommentSingle" style="%s"/>
413− <entry type="CommentSpecial" style="%s"/>
414− <entry type="CommentPreproc" style="%s"/>
415− <!-- Generic styles -->
416− <entry type="Generic" style="%s"/>
417− <entry type="GenericDeleted" style="%s"/>
418− <entry type="GenericEmph" style="italic %s"/>
419− <entry type="GenericError" style="%s"/>
420− <entry type="GenericHeading" style="bold %s"/>
421− <entry type="GenericInserted" style="%s"/>
422− <entry type="GenericOutput" style="%s"/>
423− <entry type="GenericPrompt" style="%s"/>
424− <entry type="GenericStrong" style="bold %s"/>
425− <entry type="GenericSubheading" style="bold %s"/>
426− <entry type="GenericTraceback" style="%s"/>
427− <entry type="GenericUnderline" style="underline"/>
428− <entry type="TextWhitespace" style="%s"/>
429−</style>
430−`,
431− getColor(t.Background()), // Background
432− getColor(t.Text()), // Text
433− getColor(t.Text()), // Other
434− getColor(t.Error()), // Error
435−
436− getColor(t.SyntaxKeyword()), // Keyword
437− getColor(t.SyntaxKeyword()), // KeywordConstant
438− getColor(t.SyntaxKeyword()), // KeywordDeclaration
439− getColor(t.SyntaxKeyword()), // KeywordNamespace
440− getColor(t.SyntaxKeyword()), // KeywordPseudo
441− getColor(t.SyntaxKeyword()), // KeywordReserved
442− getColor(t.SyntaxType()), // KeywordType
443−
444− getColor(t.Text()), // Name
445− getColor(t.SyntaxVariable()), // NameAttribute
446− getColor(t.SyntaxType()), // NameBuiltin
447− getColor(t.SyntaxVariable()), // NameBuiltinPseudo
448− getColor(t.SyntaxType()), // NameClass
449− getColor(t.SyntaxVariable()), // NameConstant
450− getColor(t.SyntaxFunction()), // NameDecorator
451− getColor(t.SyntaxVariable()), // NameEntity
452− getColor(t.SyntaxType()), // NameException
453− getColor(t.SyntaxFunction()), // NameFunction
454− getColor(t.Text()), // NameLabel
455− getColor(t.SyntaxType()), // NameNamespace
456− getColor(t.SyntaxVariable()), // NameOther
457− getColor(t.SyntaxKeyword()), // NameTag
458− getColor(t.SyntaxVariable()), // NameVariable
459− getColor(t.SyntaxVariable()), // NameVariableClass
460− getColor(t.SyntaxVariable()), // NameVariableGlobal
461− getColor(t.SyntaxVariable()), // NameVariableInstance
462−
463− getColor(t.SyntaxString()), // Literal
464− getColor(t.SyntaxString()), // LiteralDate
465− getColor(t.SyntaxString()), // LiteralString
466− getColor(t.SyntaxString()), // LiteralStringBacktick
467− getColor(t.SyntaxString()), // LiteralStringChar
468− getColor(t.SyntaxString()), // LiteralStringDoc
469− getColor(t.SyntaxString()), // LiteralStringDouble
470− getColor(t.SyntaxString()), // LiteralStringEscape
471− getColor(t.SyntaxString()), // LiteralStringHeredoc
472− getColor(t.SyntaxString()), // LiteralStringInterpol
473− getColor(t.SyntaxString()), // LiteralStringOther
474− getColor(t.SyntaxString()), // LiteralStringRegex
475− getColor(t.SyntaxString()), // LiteralStringSingle
476− getColor(t.SyntaxString()), // LiteralStringSymbol
477−
478− getColor(t.SyntaxNumber()), // LiteralNumber
479− getColor(t.SyntaxNumber()), // LiteralNumberBin
480− getColor(t.SyntaxNumber()), // LiteralNumberFloat
481− getColor(t.SyntaxNumber()), // LiteralNumberHex
482− getColor(t.SyntaxNumber()), // LiteralNumberInteger
483− getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
484− getColor(t.SyntaxNumber()), // LiteralNumberOct
485−
486− getColor(t.SyntaxOperator()), // Operator
487− getColor(t.SyntaxKeyword()), // OperatorWord
488− getColor(t.SyntaxPunctuation()), // Punctuation
489−
490− getColor(t.SyntaxComment()), // Comment
491− getColor(t.SyntaxComment()), // CommentHashbang
492− getColor(t.SyntaxComment()), // CommentMultiline
493− getColor(t.SyntaxComment()), // CommentSingle
494− getColor(t.SyntaxComment()), // CommentSpecial
495− getColor(t.SyntaxKeyword()), // CommentPreproc
496−
497− getColor(t.Text()), // Generic
498− getColor(t.Error()), // GenericDeleted
499− getColor(t.Text()), // GenericEmph
500− getColor(t.Error()), // GenericError
501− getColor(t.Text()), // GenericHeading
502− getColor(t.Success()), // GenericInserted
503− getColor(t.TextMuted()), // GenericOutput
504− getColor(t.Text()), // GenericPrompt
505− getColor(t.Text()), // GenericStrong
506− getColor(t.Text()), // GenericSubheading
507− getColor(t.Error()), // GenericTraceback
508− getColor(t.Text()), // TextWhitespace
509− )
510−
511− r := strings.NewReader(syntaxThemeXml)
512− style := chroma.MustNewXMLStyle(r)
513−
514− // Modify the style to use the provided background
515− s, err := style.Builder().Transform(
516− func(t chroma.StyleEntry) chroma.StyleEntry {
517− r, g, b, _ := bg.RGBA()
518− t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
519− return t
520− },
521− ).Build()
522− if err != nil {
523− s = styles.Fallback
524− }
525−
526− // Tokenize and format
527− it, err := l.Tokenise(nil, source)
528− if err != nil {
529− return err
530− }
531−
532− return f.Format(w, s, it)
533−}
534−
535320func getColor(c color.Color) string {
536321 rgba := color.RGBAModel.Convert(c).(color.RGBA)
537322 return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
@@ -539,12 +324,11 @@ func getColor(c color.Color) string {
539324
540325// highlightLine applies syntax highlighting to a single line
541326func highlightLine(fileName string, line string, bg color.Color) string {
542− var buf bytes.Buffer
543− err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
327+ highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
544328 if err != nil {
545329 return line
546330 }
547− return buf.String()
331+ return highlighted
548332}
549333
550334// createStyles generates the lipgloss styles needed for rendering diffs
@@ -561,18 +345,6 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
561345// Rendering Functions
562346// -------------------------------------------------------------------------
563347
564−func lipglossToHex(color color.Color) string {
565− r, g, b, a := color.RGBA()
566−
567− // Scale uint32 values (0-65535) to uint8 (0-255).
568− r8 := uint8(r >> 8)
569− g8 := uint8(g >> 8)
570− b8 := uint8(b >> 8)
571− a8 := uint8(a >> 8)
572−
573− return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
574−}
575−
576348// applyHighlighting applies intra-line highlighting to a piece of text
577349func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
578350 // Find all ANSI sequences in the content
@@ -614,7 +386,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
614386
615387 // Get the appropriate color based on terminal background
616388 bgColor := lipgloss.Color(getColor(highlightBg))
617− fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
389+ // fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
618390
619391 for i := 0; i < len(content); {
620392 // Check if we're at an ANSI sequence
@@ -651,15 +423,15 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
651423 currentStyle := ansiSequences[currentPos]
652424
653425 // Apply foreground and background highlight
654− sb.WriteString("\x1b[38;2;")
655− r, g, b, _ := fgColor.RGBA()
656− sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
426+ // sb.WriteString("\x1b[38;2;")
427+ // r, g, b, _ := fgColor.RGBA()
428+ // sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
657429 sb.WriteString("\x1b[48;2;")
658− r, g, b, _ = bgColor.RGBA()
430+ r, g, b, _ := bgColor.RGBA()
659431 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
660432 sb.WriteString(char)
661433 // Reset foreground and background
662− sb.WriteString("\x1b[39m")
434+ // sb.WriteString("\x1b[39m")
663435
664436 // Reapply the original ANSI sequence
665437 sb.WriteString(currentStyle)
internal/highlight/highlight.gonew file+227
@@ -0,0 +1,227 @@
1+package highlight
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "image/color"
7+ "strings"
8+
9+ "github.com/alecthomas/chroma/v2"
10+ "github.com/alecthomas/chroma/v2/formatters"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/alecthomas/chroma/v2/styles"
13+ "github.com/opencode-ai/opencode/internal/tui/theme"
14+)
15+
16+func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
17+ t := theme.CurrentTheme()
18+
19+ // Determine the language lexer to use
20+ l := lexers.Match(fileName)
21+ if l == nil {
22+ l = lexers.Analyse(source)
23+ }
24+ if l == nil {
25+ l = lexers.Fallback
26+ }
27+ l = chroma.Coalesce(l)
28+
29+ // Get the formatter
30+ f := formatters.Get("terminal16m")
31+ if f == nil {
32+ f = formatters.Fallback
33+ }
34+
35+ // Dynamic theme based on current theme values
36+ syntaxThemeXml := fmt.Sprintf(`
37+ <style name="opencode-theme">
38+ <!-- Base colors -->
39+ <entry type="Text" style="%s"/>
40+ <entry type="Other" style="%s"/>
41+ <entry type="Error" style="%s"/>
42+ <!-- Keywords -->
43+ <entry type="Keyword" style="%s"/>
44+ <entry type="KeywordConstant" style="%s"/>
45+ <entry type="KeywordDeclaration" style="%s"/>
46+ <entry type="KeywordNamespace" style="%s"/>
47+ <entry type="KeywordPseudo" style="%s"/>
48+ <entry type="KeywordReserved" style="%s"/>
49+ <entry type="KeywordType" style="%s"/>
50+ <!-- Names -->
51+ <entry type="Name" style="%s"/>
52+ <entry type="NameAttribute" style="%s"/>
53+ <entry type="NameBuiltin" style="%s"/>
54+ <entry type="NameBuiltinPseudo" style="%s"/>
55+ <entry type="NameClass" style="%s"/>
56+ <entry type="NameConstant" style="%s"/>
57+ <entry type="NameDecorator" style="%s"/>
58+ <entry type="NameEntity" style="%s"/>
59+ <entry type="NameException" style="%s"/>
60+ <entry type="NameFunction" style="%s"/>
61+ <entry type="NameLabel" style="%s"/>
62+ <entry type="NameNamespace" style="%s"/>
63+ <entry type="NameOther" style="%s"/>
64+ <entry type="NameTag" style="%s"/>
65+ <entry type="NameVariable" style="%s"/>
66+ <entry type="NameVariableClass" style="%s"/>
67+ <entry type="NameVariableGlobal" style="%s"/>
68+ <entry type="NameVariableInstance" style="%s"/>
69+ <!-- Literals -->
70+ <entry type="Literal" style="%s"/>
71+ <entry type="LiteralDate" style="%s"/>
72+ <entry type="LiteralString" style="%s"/>
73+ <entry type="LiteralStringBacktick" style="%s"/>
74+ <entry type="LiteralStringChar" style="%s"/>
75+ <entry type="LiteralStringDoc" style="%s"/>
76+ <entry type="LiteralStringDouble" style="%s"/>
77+ <entry type="LiteralStringEscape" style="%s"/>
78+ <entry type="LiteralStringHeredoc" style="%s"/>
79+ <entry type="LiteralStringInterpol" style="%s"/>
80+ <entry type="LiteralStringOther" style="%s"/>
81+ <entry type="LiteralStringRegex" style="%s"/>
82+ <entry type="LiteralStringSingle" style="%s"/>
83+ <entry type="LiteralStringSymbol" style="%s"/>
84+ <!-- Numbers -->
85+ <entry type="LiteralNumber" style="%s"/>
86+ <entry type="LiteralNumberBin" style="%s"/>
87+ <entry type="LiteralNumberFloat" style="%s"/>
88+ <entry type="LiteralNumberHex" style="%s"/>
89+ <entry type="LiteralNumberInteger" style="%s"/>
90+ <entry type="LiteralNumberIntegerLong" style="%s"/>
91+ <entry type="LiteralNumberOct" style="%s"/>
92+ <!-- Operators -->
93+ <entry type="Operator" style="%s"/>
94+ <entry type="OperatorWord" style="%s"/>
95+ <entry type="Punctuation" style="%s"/>
96+ <!-- Comments -->
97+ <entry type="Comment" style="%s"/>
98+ <entry type="CommentHashbang" style="%s"/>
99+ <entry type="CommentMultiline" style="%s"/>
100+ <entry type="CommentSingle" style="%s"/>
101+ <entry type="CommentSpecial" style="%s"/>
102+ <entry type="CommentPreproc" style="%s"/>
103+ <!-- Generic styles -->
104+ <entry type="Generic" style="%s"/>
105+ <entry type="GenericDeleted" style="%s"/>
106+ <entry type="GenericEmph" style="italic %s"/>
107+ <entry type="GenericError" style="%s"/>
108+ <entry type="GenericHeading" style="bold %s"/>
109+ <entry type="GenericInserted" style="%s"/>
110+ <entry type="GenericOutput" style="%s"/>
111+ <entry type="GenericPrompt" style="%s"/>
112+ <entry type="GenericStrong" style="bold %s"/>
113+ <entry type="GenericSubheading" style="bold %s"/>
114+ <entry type="GenericTraceback" style="%s"/>
115+ <entry type="GenericUnderline" style="underline"/>
116+ <entry type="TextWhitespace" style="%s"/>
117+</style>
118+`,
119+ getColor(t.Text()), // Text
120+ getColor(t.Text()), // Other
121+ getColor(t.Error()), // Error
122+
123+ getColor(t.SyntaxKeyword()), // Keyword
124+ getColor(t.SyntaxKeyword()), // KeywordConstant
125+ getColor(t.SyntaxKeyword()), // KeywordDeclaration
126+ getColor(t.SyntaxKeyword()), // KeywordNamespace
127+ getColor(t.SyntaxKeyword()), // KeywordPseudo
128+ getColor(t.SyntaxKeyword()), // KeywordReserved
129+ getColor(t.SyntaxType()), // KeywordType
130+
131+ getColor(t.Text()), // Name
132+ getColor(t.SyntaxVariable()), // NameAttribute
133+ getColor(t.SyntaxType()), // NameBuiltin
134+ getColor(t.SyntaxVariable()), // NameBuiltinPseudo
135+ getColor(t.SyntaxType()), // NameClass
136+ getColor(t.SyntaxVariable()), // NameConstant
137+ getColor(t.SyntaxFunction()), // NameDecorator
138+ getColor(t.SyntaxVariable()), // NameEntity
139+ getColor(t.SyntaxType()), // NameException
140+ getColor(t.SyntaxFunction()), // NameFunction
141+ getColor(t.Text()), // NameLabel
142+ getColor(t.SyntaxType()), // NameNamespace
143+ getColor(t.SyntaxVariable()), // NameOther
144+ getColor(t.SyntaxKeyword()), // NameTag
145+ getColor(t.SyntaxVariable()), // NameVariable
146+ getColor(t.SyntaxVariable()), // NameVariableClass
147+ getColor(t.SyntaxVariable()), // NameVariableGlobal
148+ getColor(t.SyntaxVariable()), // NameVariableInstance
149+
150+ getColor(t.SyntaxString()), // Literal
151+ getColor(t.SyntaxString()), // LiteralDate
152+ getColor(t.SyntaxString()), // LiteralString
153+ getColor(t.SyntaxString()), // LiteralStringBacktick
154+ getColor(t.SyntaxString()), // LiteralStringChar
155+ getColor(t.SyntaxString()), // LiteralStringDoc
156+ getColor(t.SyntaxString()), // LiteralStringDouble
157+ getColor(t.SyntaxString()), // LiteralStringEscape
158+ getColor(t.SyntaxString()), // LiteralStringHeredoc
159+ getColor(t.SyntaxString()), // LiteralStringInterpol
160+ getColor(t.SyntaxString()), // LiteralStringOther
161+ getColor(t.SyntaxString()), // LiteralStringRegex
162+ getColor(t.SyntaxString()), // LiteralStringSingle
163+ getColor(t.SyntaxString()), // LiteralStringSymbol
164+
165+ getColor(t.SyntaxNumber()), // LiteralNumber
166+ getColor(t.SyntaxNumber()), // LiteralNumberBin
167+ getColor(t.SyntaxNumber()), // LiteralNumberFloat
168+ getColor(t.SyntaxNumber()), // LiteralNumberHex
169+ getColor(t.SyntaxNumber()), // LiteralNumberInteger
170+ getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
171+ getColor(t.SyntaxNumber()), // LiteralNumberOct
172+
173+ getColor(t.SyntaxOperator()), // Operator
174+ getColor(t.SyntaxKeyword()), // OperatorWord
175+ getColor(t.SyntaxPunctuation()), // Punctuation
176+
177+ getColor(t.SyntaxComment()), // Comment
178+ getColor(t.SyntaxComment()), // CommentHashbang
179+ getColor(t.SyntaxComment()), // CommentMultiline
180+ getColor(t.SyntaxComment()), // CommentSingle
181+ getColor(t.SyntaxComment()), // CommentSpecial
182+ getColor(t.SyntaxKeyword()), // CommentPreproc
183+
184+ getColor(t.Text()), // Generic
185+ getColor(t.Error()), // GenericDeleted
186+ getColor(t.Text()), // GenericEmph
187+ getColor(t.Error()), // GenericError
188+ getColor(t.Text()), // GenericHeading
189+ getColor(t.Success()), // GenericInserted
190+ getColor(t.TextMuted()), // GenericOutput
191+ getColor(t.Text()), // GenericPrompt
192+ getColor(t.Text()), // GenericStrong
193+ getColor(t.Text()), // GenericSubheading
194+ getColor(t.Error()), // GenericTraceback
195+ getColor(t.Text()), // TextWhitespace
196+ )
197+
198+ r := strings.NewReader(syntaxThemeXml)
199+ style := chroma.MustNewXMLStyle(r)
200+
201+ // Modify the style to use the provided background
202+ s, err := style.Builder().Transform(
203+ func(t chroma.StyleEntry) chroma.StyleEntry {
204+ r, g, b, _ := bg.RGBA()
205+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
206+ return t
207+ },
208+ ).Build()
209+ if err != nil {
210+ s = styles.Fallback
211+ }
212+
213+ // Tokenize and format
214+ it, err := l.Tokenise(nil, source)
215+ if err != nil {
216+ return "", err
217+ }
218+
219+ var buf bytes.Buffer
220+ err = f.Format(&buf, s, it)
221+ return buf.String(), err
222+}
223+
224+func getColor(c color.Color) string {
225+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
226+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
227+}
internal/tui/components/chat/chat.go−1
@@ -138,4 +138,3 @@ func cwd(width int) string {
138138 Width(width).
139139 Render(cwd)
140140}
141−
internal/tui/components/chat/list_v2.gonew file+120
@@ -0,0 +1,120 @@
1+package chat
2+
3+import (
4+ "context"
5+ "time"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/opencode-ai/opencode/internal/app"
9+ "github.com/opencode-ai/opencode/internal/message"
10+ "github.com/opencode-ai/opencode/internal/session"
11+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
12+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
13+ "github.com/opencode-ai/opencode/internal/tui/layout"
14+ "github.com/opencode-ai/opencode/internal/tui/util"
15+)
16+
17+type MessageListCmp interface {
18+ util.Model
19+ layout.Sizeable
20+}
21+
22+type messageListCmp struct {
23+ app *app.App
24+ width, height int
25+ session session.Session
26+ messages []util.Model
27+ listCmp list.ListModel
28+}
29+
30+func NewMessagesListCmp(app *app.App) MessageListCmp {
31+ return &messageListCmp{
32+ app: app,
33+ listCmp: list.New(
34+ list.WithGapSize(1),
35+ list.WithReverse(true),
36+ ),
37+ }
38+}
39+
40+func (m *messageListCmp) Init() tea.Cmd {
41+ return nil
42+}
43+
44+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45+ switch msg := msg.(type) {
46+ case dialog.ThemeChangedMsg:
47+ m.listCmp.ResetView()
48+ return m, nil
49+ case SessionSelectedMsg:
50+ if msg.ID != m.session.ID {
51+ cmd := m.SetSession(msg)
52+ return m, cmd
53+ }
54+ return m, nil
55+ }
56+ return m, nil
57+}
58+
59+func (m *messageListCmp) View() string {
60+ return m.listCmp.View()
61+}
62+
63+// GetSize implements MessageListCmp.
64+func (m *messageListCmp) GetSize() (int, int) {
65+ return m.width, m.height
66+}
67+
68+// SetSize implements MessageListCmp.
69+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
70+ m.width = width
71+ m.height = height
72+ return m.listCmp.SetSize(width, height)
73+}
74+
75+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
76+ if m.session.ID == session.ID {
77+ return nil
78+ }
79+ m.session = session
80+ messages, err := m.app.Messages.List(context.Background(), session.ID)
81+ if err != nil {
82+ return util.ReportError(err)
83+ }
84+ m.messages = make([]util.Model, 0)
85+ lastUserMessageTime := messages[0].CreatedAt
86+ toolResultMap := make(map[string]message.ToolResult)
87+ // first pass to get all tool results
88+ for _, msg := range messages {
89+ for _, tr := range msg.ToolResults() {
90+ toolResultMap[tr.ToolCallID] = tr
91+ }
92+ }
93+ for _, msg := range messages {
94+ // TODO: handle tool calls and others here
95+ switch msg.Role {
96+ case message.User:
97+ lastUserMessageTime = msg.CreatedAt
98+ m.messages = append(m.messages, NewMessageCmp(WithMessage(msg)))
99+ case message.Assistant:
100+ // Only add assistant messages if they don't have tool calls or there is some content
101+ if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
102+ m.messages = append(m.messages, NewMessageCmp(WithMessage(msg), WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
103+ }
104+ for _, tc := range msg.ToolCalls() {
105+ options := []MessageOption{
106+ WithToolCall(tc),
107+ }
108+ if tr, ok := toolResultMap[tc.ID]; ok {
109+ options = append(options, WithToolResult(tr))
110+ }
111+ if msg.FinishPart().Reason == message.FinishReasonCanceled {
112+ options = append(options, WithCancelledToolCall(true))
113+ }
114+ m.messages = append(m.messages, NewMessageCmp(options...))
115+ }
116+ }
117+ }
118+ m.listCmp.SetItems(m.messages)
119+ return nil
120+}
internal/tui/components/chat/message.go−69
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/charmbracelet/lipgloss/v2"
1212 "github.com/charmbracelet/x/ansi"
13− "github.com/opencode-ai/opencode/internal/config"
1413 "github.com/opencode-ai/opencode/internal/diff"
1514 "github.com/opencode-ai/opencode/internal/llm/agent"
1615 "github.com/opencode-ai/opencode/internal/llm/models"
@@ -272,66 +271,6 @@ func getToolAction(name string) string {
272271 return "Working..."
273272}
274273
275−// renders params, params[0] (params[1]=params[2] ....)
276−func renderParams(paramsWidth int, params ...string) string {
277− if len(params) == 0 {
278− return ""
279− }
280− mainParam := params[0]
281− if len(mainParam) > paramsWidth {
282− mainParam = mainParam[:paramsWidth-3] + "..."
283− }
284−
285− if len(params) == 1 {
286− return mainParam
287− }
288− otherParams := params[1:]
289− // create pairs of key/value
290− // if odd number of params, the last one is a key without value
291− if len(otherParams)%2 != 0 {
292− otherParams = append(otherParams, "")
293− }
294− parts := make([]string, 0, len(otherParams)/2)
295− for i := 0; i < len(otherParams); i += 2 {
296− key := otherParams[i]
297− value := otherParams[i+1]
298− if value == "" {
299− continue
300− }
301− parts = append(parts, fmt.Sprintf("%s=%s", key, value))
302− }
303−
304− partsRendered := strings.Join(parts, ", ")
305− remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
306− if remainingWidth < 30 {
307− // No space for the params, just show the main
308− return mainParam
309− }
310−
311− if len(parts) > 0 {
312− mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
313− }
314−
315− return ansi.Truncate(mainParam, paramsWidth, "...")
316−}
317−
318−func removeWorkingDirPrefix(path string) string {
319− wd := config.WorkingDirectory()
320− if strings.HasPrefix(path, wd) {
321− path = strings.TrimPrefix(path, wd)
322− }
323− if strings.HasPrefix(path, "/") {
324− path = strings.TrimPrefix(path, "/")
325− }
326− if strings.HasPrefix(path, "./") {
327− path = strings.TrimPrefix(path, "./")
328− }
329− if strings.HasPrefix(path, "../") {
330− path = strings.TrimPrefix(path, "../")
331− }
332− return path
333−}
334−
335274func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
336275 params := ""
337276 switch toolCall.Name {
@@ -430,14 +369,6 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
430369 return params
431370}
432371
433−func truncateHeight(content string, height int) string {
434− lines := strings.Split(content, "\n")
435− if len(lines) > height {
436− return strings.Join(lines[:height], "\n")
437− }
438− return content
439−}
440−
441372func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
442373 t := theme.CurrentTheme()
443374 baseStyle := styles.BaseStyle()
internal/tui/components/chat/message_v2.gonew file+244
@@ -0,0 +1,244 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "path/filepath"
7+ "strings"
8+ "time"
9+
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/opencode-ai/opencode/internal/llm/models"
13+
14+ "github.com/opencode-ai/opencode/internal/message"
15+ "github.com/opencode-ai/opencode/internal/tui/layout"
16+ "github.com/opencode-ai/opencode/internal/tui/styles"
17+ "github.com/opencode-ai/opencode/internal/tui/theme"
18+ "github.com/opencode-ai/opencode/internal/tui/util"
19+)
20+
21+type MessageCmp interface {
22+ util.Model
23+ layout.Sizeable
24+ layout.Focusable
25+}
26+
27+type messageCmp struct {
28+ width int
29+ focused bool
30+
31+ // Used for agent and user messages
32+ message message.Message
33+ lastUserMessageTime time.Time
34+
35+ // Used for tool calls
36+ toolCall message.ToolCall
37+ toolResult message.ToolResult
38+ cancelledToolCall bool
39+}
40+
41+type MessageOption func(*messageCmp)
42+
43+func WithLastUserMessageTime(t time.Time) MessageOption {
44+ return func(m *messageCmp) {
45+ m.lastUserMessageTime = t
46+ }
47+}
48+
49+func WithToolCall(tc message.ToolCall) MessageOption {
50+ return func(m *messageCmp) {
51+ m.toolCall = tc
52+ }
53+}
54+
55+func WithToolResult(tr message.ToolResult) MessageOption {
56+ return func(m *messageCmp) {
57+ m.toolResult = tr
58+ }
59+}
60+
61+func WithMessage(msg message.Message) MessageOption {
62+ return func(m *messageCmp) {
63+ m.message = msg
64+ }
65+}
66+
67+func WithCancelledToolCall(cancelled bool) MessageOption {
68+ return func(m *messageCmp) {
69+ m.cancelledToolCall = cancelled
70+ }
71+}
72+
73+func NewMessageCmp(opts ...MessageOption) MessageCmp {
74+ m := &messageCmp{}
75+ for _, opt := range opts {
76+ opt(m)
77+ }
78+ return m
79+}
80+
81+func (m *messageCmp) Init() tea.Cmd {
82+ return nil
83+}
84+
85+func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86+ return m, nil
87+}
88+
89+func (m *messageCmp) View() string {
90+ if m.message.ID != "" {
91+ // this is a user or assistant message
92+ switch m.message.Role {
93+ case message.User:
94+ return m.renderUserMessage()
95+ default:
96+ return m.renderAssistantMessage()
97+ }
98+ } else if m.toolCall.ID != "" {
99+ // this is a tool call message
100+ return m.renderToolCallMessage()
101+ }
102+ return "Unknown Message"
103+}
104+
105+func (m *messageCmp) textWidth() int {
106+ if m.toolCall.ID != "" {
107+ return m.width - 2 // take into account the border and PaddingLeft
108+ }
109+ return m.width - 1 // take into account the border
110+}
111+
112+func (msg *messageCmp) style() lipgloss.Style {
113+ t := theme.CurrentTheme()
114+ var borderColor color.Color
115+ borderStyle := lipgloss.NormalBorder()
116+ if msg.focused {
117+ borderStyle = lipgloss.DoubleBorder()
118+ }
119+
120+ switch msg.message.Role {
121+ case message.User:
122+ borderColor = t.Secondary()
123+ case message.Assistant:
124+ borderColor = t.Primary()
125+ default:
126+ // Tool call
127+ borderColor = t.TextMuted()
128+ }
129+
130+ return styles.BaseStyle().
131+ BorderLeft(true).
132+ Foreground(t.TextMuted()).
133+ BorderForeground(borderColor).
134+ BorderStyle(borderStyle)
135+}
136+
137+func (m *messageCmp) renderAssistantMessage() string {
138+ parts := []string{
139+ m.markdownContent(),
140+ }
141+
142+ finished := m.message.IsFinished()
143+ finishData := m.message.FinishPart()
144+ // Only show the footer if the message is not a tool call
145+ if finished && finishData.Reason != message.FinishReasonToolUse {
146+ infoMsg := ""
147+ switch finishData.Reason {
148+ case message.FinishReasonEndTurn:
149+ finishTime := time.Unix(finishData.Time, 0)
150+ duration := finishTime.Sub(m.lastUserMessageTime)
151+ infoMsg = duration.String()
152+ case message.FinishReasonCanceled:
153+ infoMsg = "canceled"
154+ case message.FinishReasonError:
155+ infoMsg = "error"
156+ case message.FinishReasonPermissionDenied:
157+ infoMsg = "permission denied"
158+ }
159+ parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
160+ }
161+
162+ joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
163+ return m.style().Render(joined)
164+}
165+
166+func (m *messageCmp) renderUserMessage() string {
167+ t := theme.CurrentTheme()
168+ parts := []string{
169+ m.markdownContent(),
170+ }
171+ attachmentStyles := styles.BaseStyle().
172+ MarginLeft(1).
173+ Background(t.BackgroundSecondary()).
174+ Foreground(t.Text())
175+ attachments := []string{}
176+ for _, attachment := range m.message.BinaryContent() {
177+ file := filepath.Base(attachment.Path)
178+ var filename string
179+ if len(file) > 10 {
180+ filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
181+ } else {
182+ filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
183+ }
184+ attachments = append(attachments, attachmentStyles.Render(filename))
185+ }
186+ if len(attachments) > 0 {
187+ parts = append(parts, "", strings.Join(attachments, ""))
188+ }
189+ joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
190+ return m.style().Render(joined)
191+}
192+
193+func (m *messageCmp) toMarkdown(content string) string {
194+ r := styles.GetMarkdownRenderer(m.textWidth())
195+ rendered, _ := r.Render(content)
196+ return strings.TrimSuffix(rendered, "\n")
197+}
198+
199+func (m *messageCmp) markdownContent() string {
200+ content := m.message.Content().String()
201+ if m.message.Role == message.Assistant {
202+ thinking := m.message.IsThinking()
203+ finished := m.message.IsFinished()
204+ finishedData := m.message.FinishPart()
205+ if thinking {
206+ // Handle the thinking state
207+ // TODO: maybe add the thinking content if available later.
208+ content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
209+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
210+ // Sometimes the LLMs respond with no content when they think the previous tool result
211+ // provides the requested question
212+ content = "*Finished without output*"
213+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
214+ content = "*Canceled*"
215+ }
216+ }
217+ return m.toMarkdown(content)
218+}
219+
220+// Blur implements MessageModel.
221+func (m *messageCmp) Blur() tea.Cmd {
222+ m.focused = false
223+ return nil
224+}
225+
226+// Focus implements MessageModel.
227+func (m *messageCmp) Focus() tea.Cmd {
228+ m.focused = true
229+ return nil
230+}
231+
232+// IsFocused implements MessageModel.
233+func (m *messageCmp) IsFocused() bool {
234+ return m.focused
235+}
236+
237+func (m *messageCmp) GetSize() (int, int) {
238+ return m.width, 0
239+}
240+
241+func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
242+ m.width = width
243+ return nil
244+}
internal/tui/components/chat/sidebar.go+1−1
@@ -181,7 +181,7 @@ func (m *sidebarCmp) modifiedFiles() string {
181181 Render("Modified Files:")
182182
183183 // If no modified files, show a placeholder message
184− if m.modFiles == nil || len(m.modFiles) == 0 {
184+ if len(m.modFiles) == 0 {
185185 message := "No modified files"
186186 remainingWidth := m.width - lipgloss.Width(message)
187187 if remainingWidth > 0 {
internal/tui/components/chat/tool_message.gonew file+365
@@ -0,0 +1,365 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/lipgloss/v2"
9+ "github.com/charmbracelet/x/ansi"
10+ "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/opencode-ai/opencode/internal/diff"
12+ "github.com/opencode-ai/opencode/internal/highlight"
13+ "github.com/opencode-ai/opencode/internal/llm/agent"
14+ "github.com/opencode-ai/opencode/internal/llm/tools"
15+ "github.com/opencode-ai/opencode/internal/tui/styles"
16+ "github.com/opencode-ai/opencode/internal/tui/theme"
17+)
18+
19+const responseContextHeight = 10
20+
21+func (m *messageCmp) renderUnfinishedToolCall() string {
22+ toolName := m.toolName()
23+ toolAction := m.getToolAction()
24+ return fmt.Sprintf("%s: %s", toolName, toolAction)
25+}
26+
27+func (m *messageCmp) renderToolError() string {
28+ t := theme.CurrentTheme()
29+ baseStyle := styles.BaseStyle()
30+ err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
31+ err = fmt.Sprintf("Error: %s", err)
32+ return baseStyle.Foreground(t.Error()).Render(m.fit(err))
33+}
34+
35+func (m *messageCmp) renderBashTool() string {
36+ name := m.toolName()
37+ prefix := fmt.Sprintf("%s: ", name)
38+ var params tools.BashParams
39+ json.Unmarshal([]byte(m.toolCall.Input), &params)
40+ command := strings.ReplaceAll(params.Command, "\n", " ")
41+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
42+
43+ if result, ok := m.toolResultErrorOrMissing(header); ok {
44+ return result
45+ }
46+ return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
47+}
48+
49+func (m *messageCmp) renderViewTool() string {
50+ name := m.toolName()
51+ prefix := fmt.Sprintf("%s: ", name)
52+ var params tools.ViewParams
53+ json.Unmarshal([]byte(m.toolCall.Input), &params)
54+ filePath := removeWorkingDirPrefix(params.FilePath)
55+ toolParams := []string{
56+ filePath,
57+ }
58+ if params.Limit != 0 {
59+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
60+ }
61+ if params.Offset != 0 {
62+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
63+ }
64+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
65+
66+ if result, ok := m.toolResultErrorOrMissing(header); ok {
67+ return result
68+ }
69+
70+ metadata := tools.ViewResponseMetadata{}
71+ json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
72+
73+ return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
74+}
75+
76+func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
77+ t := theme.CurrentTheme()
78+ originalHeight := lipgloss.Height(content)
79+ fileContent := truncateHeight(content, responseContextHeight)
80+
81+ highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
82+
83+ lines := strings.Split(highlighted, "\n")
84+
85+ if originalHeight > responseContextHeight {
86+ lines = append(lines,
87+ lipgloss.NewStyle().Background(t.BackgroundSecondary()).
88+ Foreground(t.TextMuted()).
89+ Render(
90+ fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
91+ ),
92+ )
93+ }
94+ for i, line := range lines {
95+ lineNumber := lipgloss.NewStyle().
96+ PaddingLeft(4).
97+ PaddingRight(2).
98+ Background(t.BackgroundSecondary()).
99+ Foreground(t.TextMuted()).
100+ Render(fmt.Sprintf("%d", i+1+offset))
101+ formattedLine := lipgloss.NewStyle().
102+ Width(m.textWidth() - lipgloss.Width(lineNumber)).
103+ Background(t.BackgroundSecondary()).Render(line)
104+ lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
105+ }
106+ return lipgloss.NewStyle().Render(
107+ lipgloss.JoinVertical(
108+ lipgloss.Left,
109+ lines...,
110+ ),
111+ )
112+}
113+
114+func (m *messageCmp) renderPlainContent(content string) string {
115+ t := theme.CurrentTheme()
116+ content = strings.TrimSuffix(content, "\n")
117+ content = strings.TrimPrefix(content, "\n")
118+ lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
119+
120+ for i, line := range lines {
121+ line = " " + line // add padding
122+ if len(line) > m.textWidth() {
123+ line = m.fit(line)
124+ }
125+ lines[i] = lipgloss.NewStyle().
126+ Width(m.textWidth()).
127+ Background(t.BackgroundSecondary()).
128+ Foreground(t.TextMuted()).
129+ Render(line)
130+ }
131+ if len(lines) > responseContextHeight {
132+ lines = lines[:responseContextHeight]
133+ lines = append(lines,
134+ lipgloss.NewStyle().Background(t.BackgroundSecondary()).
135+ Foreground(t.TextMuted()).
136+ Render(
137+ fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
138+ ),
139+ )
140+ }
141+ return strings.Join(lines, "\n")
142+}
143+
144+func (m *messageCmp) renderGenericTool() string {
145+ // Tool params
146+ name := m.toolName()
147+ prefix := fmt.Sprintf("%s: ", name)
148+ input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
149+ params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
150+ header := prefix + params
151+
152+ if result, ok := m.toolResultErrorOrMissing(header); ok {
153+ return result
154+ }
155+ return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
156+}
157+
158+func (m *messageCmp) renderEditTool() string {
159+ // Tool params
160+ name := m.toolName()
161+ prefix := fmt.Sprintf("%s: ", name)
162+ var params tools.EditParams
163+ json.Unmarshal([]byte(m.toolCall.Input), &params)
164+ filePath := removeWorkingDirPrefix(params.FilePath)
165+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
166+
167+ if result, ok := m.toolResultErrorOrMissing(header); ok {
168+ return result
169+ }
170+ metadata := tools.EditResponseMetadata{}
171+ json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
172+ truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
173+ formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
174+ return m.renderTool(header, formattedDiff)
175+}
176+
177+func (m *messageCmp) renderWriteTool() string {
178+ // Tool params
179+ name := m.toolName()
180+ prefix := fmt.Sprintf("%s: ", name)
181+ var params tools.WriteParams
182+ json.Unmarshal([]byte(m.toolCall.Input), &params)
183+ filePath := removeWorkingDirPrefix(params.FilePath)
184+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
185+ if result, ok := m.toolResultErrorOrMissing(header); ok {
186+ return result
187+ }
188+ return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
189+}
190+
191+func (m *messageCmp) renderToolCallMessage() string {
192+ if !m.toolCall.Finished && !m.cancelledToolCall {
193+ return m.renderUnfinishedToolCall()
194+ }
195+ content := ""
196+ switch m.toolCall.Name {
197+ case tools.ViewToolName:
198+ content = m.renderViewTool()
199+ case tools.BashToolName:
200+ content = m.renderBashTool()
201+ case tools.EditToolName:
202+ content = m.renderEditTool()
203+ case tools.WriteToolName:
204+ content = m.renderWriteTool()
205+ default:
206+ content = m.renderGenericTool()
207+ }
208+ return m.style().PaddingLeft(1).Render(content)
209+}
210+
211+func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
212+ result := "Waiting for tool to finish..."
213+ if m.toolResult.IsError {
214+ result = m.renderToolError()
215+ return lipgloss.JoinVertical(
216+ lipgloss.Left,
217+ header,
218+ result,
219+ ), true
220+ } else if m.cancelledToolCall {
221+ result = "Cancelled"
222+ return lipgloss.JoinVertical(
223+ lipgloss.Left,
224+ header,
225+ result,
226+ ), true
227+ } else if m.toolResult.ToolCallID == "" {
228+ return lipgloss.JoinVertical(
229+ lipgloss.Left,
230+ header,
231+ result,
232+ ), true
233+ }
234+
235+ return "", false
236+}
237+
238+func (m *messageCmp) renderTool(header, result string) string {
239+ return lipgloss.JoinVertical(
240+ lipgloss.Left,
241+ header,
242+ "",
243+ result,
244+ "",
245+ )
246+}
247+
248+func removeWorkingDirPrefix(path string) string {
249+ wd := config.WorkingDirectory()
250+ path = strings.TrimPrefix(path, wd)
251+ return path
252+}
253+
254+func truncateHeight(content string, height int) string {
255+ lines := strings.Split(content, "\n")
256+ if len(lines) > height {
257+ return strings.Join(lines[:height], "\n")
258+ }
259+ return content
260+}
261+
262+func (m *messageCmp) fit(content string) string {
263+ return ansi.Truncate(content, m.textWidth(), "...")
264+}
265+
266+func (m *messageCmp) toolName() string {
267+ switch m.toolCall.Name {
268+ case agent.AgentToolName:
269+ return "Task"
270+ case tools.BashToolName:
271+ return "Bash"
272+ case tools.EditToolName:
273+ return "Edit"
274+ case tools.FetchToolName:
275+ return "Fetch"
276+ case tools.GlobToolName:
277+ return "Glob"
278+ case tools.GrepToolName:
279+ return "Grep"
280+ case tools.LSToolName:
281+ return "List"
282+ case tools.SourcegraphToolName:
283+ return "Sourcegraph"
284+ case tools.ViewToolName:
285+ return "View"
286+ case tools.WriteToolName:
287+ return "Write"
288+ case tools.PatchToolName:
289+ return "Patch"
290+ default:
291+ return m.toolCall.Name
292+ }
293+}
294+
295+func (m *messageCmp) getToolAction() string {
296+ switch m.toolCall.Name {
297+ case agent.AgentToolName:
298+ return "Preparing prompt..."
299+ case tools.BashToolName:
300+ return "Building command..."
301+ case tools.EditToolName:
302+ return "Preparing edit..."
303+ case tools.FetchToolName:
304+ return "Writing fetch..."
305+ case tools.GlobToolName:
306+ return "Finding files..."
307+ case tools.GrepToolName:
308+ return "Searching content..."
309+ case tools.LSToolName:
310+ return "Listing directory..."
311+ case tools.SourcegraphToolName:
312+ return "Searching code..."
313+ case tools.ViewToolName:
314+ return "Reading file..."
315+ case tools.WriteToolName:
316+ return "Preparing write..."
317+ case tools.PatchToolName:
318+ return "Preparing patch..."
319+ default:
320+ return "Working..."
321+ }
322+}
323+
324+// renders params, params[0] (params[1]=params[2] ....)
325+func renderParams(paramsWidth int, params ...string) string {
326+ if len(params) == 0 {
327+ return ""
328+ }
329+ mainParam := params[0]
330+ if len(mainParam) > paramsWidth {
331+ mainParam = mainParam[:paramsWidth-3] + "..."
332+ }
333+
334+ if len(params) == 1 {
335+ return mainParam
336+ }
337+ otherParams := params[1:]
338+ // create pairs of key/value
339+ // if odd number of params, the last one is a key without value
340+ if len(otherParams)%2 != 0 {
341+ otherParams = append(otherParams, "")
342+ }
343+ parts := make([]string, 0, len(otherParams)/2)
344+ for i := 0; i < len(otherParams); i += 2 {
345+ key := otherParams[i]
346+ value := otherParams[i+1]
347+ if value == "" {
348+ continue
349+ }
350+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
351+ }
352+
353+ partsRendered := strings.Join(parts, ", ")
354+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
355+ if remainingWidth < 30 {
356+ // No space for the params, just show the main
357+ return mainParam
358+ }
359+
360+ if len(parts) > 0 {
361+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
362+ }
363+
364+ return ansi.Truncate(mainParam, paramsWidth, "...")
365+}
internal/tui/components/core/list/keys.gonew file+70
@@ -0,0 +1,70 @@
1+package list
2+
3+import "github.com/charmbracelet/bubbles/v2/key"
4+
5+type KeyMap struct {
6+ Down,
7+ Up,
8+ NDown,
9+ NUp,
10+ DownOneItem,
11+ UpOneItem,
12+ HalfPageDown,
13+ HalfPageUp,
14+ Home,
15+ End,
16+ Submit key.Binding
17+}
18+
19+func defaultKeymap() KeyMap {
20+ return KeyMap{
21+ Down: key.NewBinding(
22+ key.WithKeys("down", "ctrl+j", "ctrl+n"),
23+ ),
24+ Up: key.NewBinding(
25+ key.WithKeys("up", "ctrl+k", "ctrl+p"),
26+ ),
27+ NDown: key.NewBinding(
28+ key.WithKeys("j"),
29+ ),
30+ NUp: key.NewBinding(
31+ key.WithKeys("k"),
32+ ),
33+ UpOneItem: key.NewBinding(
34+ key.WithKeys("shift+up"),
35+ ),
36+ DownOneItem: key.NewBinding(
37+ key.WithKeys("shift+down"),
38+ ),
39+ HalfPageDown: key.NewBinding(
40+ key.WithKeys("ctrl+d"),
41+ ),
42+ HalfPageUp: key.NewBinding(
43+ key.WithKeys("ctrl+u"),
44+ ),
45+ Home: key.NewBinding(
46+ key.WithKeys("g", "home"),
47+ ),
48+ End: key.NewBinding(
49+ key.WithKeys("shift+g", "end"),
50+ ),
51+ Submit: key.NewBinding(
52+ key.WithKeys("enter", "space"),
53+ key.WithHelp("enter/space", "select"),
54+ ),
55+ }
56+}
57+
58+// FullHelp implements help.KeyMap.
59+func (k KeyMap) FullHelp() [][]key.Binding { return nil }
60+
61+// ShortHelp implements help.KeyMap.
62+func (k KeyMap) ShortHelp() []key.Binding {
63+ return []key.Binding{
64+ key.NewBinding(
65+ key.WithKeys("up", "down"),
66+ key.WithHelp("↓↑", "navigate"),
67+ ),
68+ k.Submit,
69+ }
70+}
internal/tui/components/core/list/list.gonew file+625
@@ -0,0 +1,625 @@
1+package list
2+
3+import (
4+ "slices"
5+ "strings"
6+ "sync"
7+
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/opencode-ai/opencode/internal/tui/layout"
13+ "github.com/opencode-ai/opencode/internal/tui/util"
14+)
15+
16+type ListModel interface {
17+ util.Model
18+ layout.Sizeable
19+ SetItems([]util.Model) tea.Cmd
20+ AppendItem(util.Model)
21+ PrependItem(util.Model)
22+ DeleteItem(int)
23+ UpdateItem(int, util.Model)
24+ ResetView()
25+}
26+
27+type renderedItem struct {
28+ lines []string
29+ start int
30+ height int
31+}
32+type model struct {
33+ width, height, offset int
34+ finalHight int // this gets set when the last item is rendered to mark the max offset
35+ reverse bool
36+ help help.Model
37+ keymap KeyMap
38+ items []util.Model
39+ renderedItems *sync.Map // item index to rendered string
40+ needsRerender bool
41+ renderedLines []string
42+ selectedItemInx int
43+ lastRenderedInx int
44+ content string
45+ gapSize int
46+ padding []int
47+}
48+
49+type listOptions func(*model)
50+
51+func WithKeyMap(k KeyMap) listOptions {
52+ return func(m *model) {
53+ m.keymap = k
54+ }
55+}
56+
57+func WithReverse(reverse bool) listOptions {
58+ return func(m *model) {
59+ m.setReverse(reverse)
60+ }
61+}
62+
63+func WithGapSize(gapSize int) listOptions {
64+ return func(m *model) {
65+ m.gapSize = gapSize
66+ }
67+}
68+
69+func WithPadding(padding ...int) listOptions {
70+ return func(m *model) {
71+ m.padding = padding
72+ }
73+}
74+
75+func WithItems(items []util.Model) listOptions {
76+ return func(m *model) {
77+ m.items = items
78+ }
79+}
80+
81+func New(opts ...listOptions) ListModel {
82+ m := &model{
83+ help: help.New(),
84+ keymap: defaultKeymap(),
85+ items: []util.Model{},
86+ needsRerender: true,
87+ gapSize: 0,
88+ padding: []int{},
89+ selectedItemInx: -1,
90+ finalHight: -1,
91+ lastRenderedInx: -1,
92+ renderedItems: new(sync.Map),
93+ }
94+ for _, opt := range opts {
95+ opt(m)
96+ }
97+ return m
98+}
99+
100+// Init implements List.
101+func (m *model) Init() tea.Cmd {
102+ cmds := []tea.Cmd{
103+ m.SetItems(m.items),
104+ }
105+ return tea.Batch(cmds...)
106+}
107+
108+// Update implements List.
109+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110+ switch msg := msg.(type) {
111+ case tea.KeyMsg:
112+ switch {
113+ case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
114+ if m.reverse {
115+ m.decreaseOffset(1)
116+ } else {
117+ m.increaseOffset(1)
118+ }
119+ return m, nil
120+ case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
121+ if m.reverse {
122+ m.increaseOffset(1)
123+ } else {
124+ m.decreaseOffset(1)
125+ }
126+ return m, nil
127+ case key.Matches(msg, m.keymap.DownOneItem):
128+ m.downOneItem()
129+ return m, nil
130+ case key.Matches(msg, m.keymap.UpOneItem):
131+ m.upOneItem()
132+ return m, nil
133+ case key.Matches(msg, m.keymap.HalfPageDown):
134+ if m.reverse {
135+ m.decreaseOffset(m.listHeight() / 2)
136+ } else {
137+ m.increaseOffset(m.listHeight() / 2)
138+ }
139+ return m, nil
140+ case key.Matches(msg, m.keymap.HalfPageUp):
141+ if m.reverse {
142+ m.increaseOffset(m.listHeight() / 2)
143+ } else {
144+ m.decreaseOffset(m.listHeight() / 2)
145+ }
146+ return m, nil
147+ case key.Matches(msg, m.keymap.Home):
148+ m.goToTop()
149+ return m, nil
150+ case key.Matches(msg, m.keymap.End):
151+ m.goToBottom()
152+ return m, nil
153+ }
154+ }
155+ if m.selectedItemInx > -1 {
156+ u, cmd := m.items[m.selectedItemInx].Update(msg)
157+ m.UpdateItem(m.selectedItemInx, u.(util.Model))
158+ return m, cmd
159+ }
160+
161+ return m, nil
162+}
163+
164+// View implements List.
165+func (m *model) View() string {
166+ if m.height == 0 || m.width == 0 {
167+ return ""
168+ }
169+ if m.needsRerender {
170+ m.renderVisible()
171+ }
172+ return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
173+}
174+
175+func (m *model) renderVisibleReverse() {
176+ start := 0
177+ cutoff := m.offset + m.listHeight()
178+ items := m.items
179+ if m.lastRenderedInx > -1 {
180+ items = m.items[:m.lastRenderedInx]
181+ start = len(m.renderedLines)
182+ } else {
183+ // reveresed so that it starts at the end
184+ m.lastRenderedInx = len(m.items)
185+ }
186+ realIndex := m.lastRenderedInx
187+ for i := len(items) - 1; i >= 0; i-- {
188+ realIndex--
189+ var itemLines []string
190+ cachedContent, ok := m.renderedItems.Load(realIndex)
191+ if ok {
192+ itemLines = cachedContent.(renderedItem).lines
193+ } else {
194+ itemLines = strings.Split(items[i].View(), "\n")
195+ if m.gapSize > 0 && realIndex != len(m.items)-1 {
196+ for range m.gapSize {
197+ itemLines = append(itemLines, "")
198+ }
199+ }
200+ m.renderedItems.Store(realIndex, renderedItem{
201+ lines: itemLines,
202+ start: start,
203+ height: len(itemLines),
204+ })
205+ }
206+
207+ if realIndex == 0 {
208+ m.finalHight = max(0, start+len(itemLines)-m.listHeight())
209+ }
210+ m.renderedLines = append(itemLines, m.renderedLines...)
211+ m.lastRenderedInx = realIndex
212+ // always render the next item
213+ if start > cutoff {
214+ break
215+ }
216+ start += len(itemLines)
217+ }
218+ m.needsRerender = false
219+ if m.finalHight > -1 {
220+ // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
221+ m.offset = min(m.offset, m.finalHight)
222+ }
223+ maxHeight := min(m.listHeight(), len(m.renderedLines))
224+ if m.offset < len(m.renderedLines) {
225+ end := len(m.renderedLines) - m.offset
226+ start := max(0, end-maxHeight)
227+ m.content = strings.Join(m.renderedLines[start:end], "\n")
228+ } else {
229+ m.content = ""
230+ }
231+}
232+
233+func (m *model) renderVisible() {
234+ if m.reverse {
235+ m.renderVisibleReverse()
236+ return
237+ }
238+ start := 0
239+ cutoff := m.offset + m.listHeight()
240+ items := m.items
241+ if m.lastRenderedInx > -1 {
242+ items = m.items[m.lastRenderedInx+1:]
243+ start = len(m.renderedLines)
244+ }
245+
246+ realIndex := m.lastRenderedInx
247+ for _, item := range items {
248+ realIndex++
249+
250+ var itemLines []string
251+ cachedContent, ok := m.renderedItems.Load(realIndex)
252+ if ok {
253+ itemLines = cachedContent.(renderedItem).lines
254+ } else {
255+ itemLines = strings.Split(item.View(), "\n")
256+ if m.gapSize > 0 && realIndex != len(m.items)-1 {
257+ for range m.gapSize {
258+ itemLines = append(itemLines, "")
259+ }
260+ }
261+ m.renderedItems.Store(realIndex, renderedItem{
262+ lines: itemLines,
263+ start: start,
264+ height: len(itemLines),
265+ })
266+ }
267+ // always render the next item
268+ if start > cutoff {
269+ break
270+ }
271+
272+ if realIndex == len(m.items)-1 {
273+ m.finalHight = max(0, start+len(itemLines)-m.listHeight())
274+ }
275+
276+ m.renderedLines = append(m.renderedLines, itemLines...)
277+ m.lastRenderedInx = realIndex
278+ start += len(itemLines)
279+ }
280+ m.needsRerender = false
281+ maxHeight := min(m.listHeight(), len(m.renderedLines))
282+ if m.finalHight > -1 {
283+ // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
284+ m.offset = min(m.offset, m.finalHight)
285+ }
286+ if m.offset < len(m.renderedLines) {
287+ m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
288+ } else {
289+ m.content = ""
290+ }
291+}
292+
293+func (m *model) upOneItem() tea.Cmd {
294+ var cmds []tea.Cmd
295+ if m.selectedItemInx > 0 {
296+ cmd := m.blurSelected()
297+ cmds = append(cmds, cmd)
298+ m.selectedItemInx--
299+ cmd = m.focusSelected()
300+ cmds = append(cmds, cmd)
301+ }
302+
303+ cached, ok := m.renderedItems.Load(m.selectedItemInx)
304+ if ok {
305+ // already rendered
306+ if !m.reverse {
307+ cachedItem, _ := cached.(renderedItem)
308+ // might not fit on the screen move the offset to the start of the item
309+ if cachedItem.height >= m.listHeight() {
310+ changeNeeded := m.offset - cachedItem.start
311+ m.decreaseOffset(changeNeeded)
312+ }
313+ if cachedItem.start < m.offset {
314+ changeNeeded := m.offset - cachedItem.start
315+ m.decreaseOffset(changeNeeded)
316+ }
317+ } else {
318+ cachedItem, _ := cached.(renderedItem)
319+ // might not fit on the screen move the offset to the start of the item
320+ if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
321+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
322+ m.increaseOffset(changeNeeded)
323+ }
324+ }
325+ }
326+ m.needsRerender = true
327+ return tea.Batch(cmds...)
328+}
329+
330+func (m *model) downOneItem() tea.Cmd {
331+ var cmds []tea.Cmd
332+ if m.selectedItemInx < len(m.items)-1 {
333+ cmd := m.blurSelected()
334+ cmds = append(cmds, cmd)
335+ m.selectedItemInx++
336+ cmd = m.focusSelected()
337+ cmds = append(cmds, cmd)
338+ }
339+ cached, ok := m.renderedItems.Load(m.selectedItemInx)
340+ if ok {
341+ // already rendered
342+ if !m.reverse {
343+ cachedItem, _ := cached.(renderedItem)
344+ // might not fit on the screen move the offset to the start of the item
345+ if cachedItem.height >= m.listHeight() {
346+ changeNeeded := cachedItem.start - m.offset
347+ m.increaseOffset(changeNeeded)
348+ } else {
349+ end := cachedItem.start + cachedItem.height
350+ if end > m.offset+m.listHeight() {
351+ changeNeeded := end - (m.offset + m.listHeight())
352+ m.increaseOffset(changeNeeded)
353+ }
354+ }
355+ } else {
356+ cachedItem, _ := cached.(renderedItem)
357+ // might not fit on the screen move the offset to the start of the item
358+ if cachedItem.height >= m.listHeight() {
359+ changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
360+ m.decreaseOffset(changeNeeded)
361+ } else {
362+ if cachedItem.start < m.offset {
363+ changeNeeded := m.offset - cachedItem.start
364+ m.decreaseOffset(changeNeeded)
365+ }
366+ }
367+ }
368+ }
369+
370+ m.needsRerender = true
371+ return tea.Batch(cmds...)
372+}
373+
374+func (m *model) goToBottom() tea.Cmd {
375+ var cmds []tea.Cmd
376+ m.reverse = true
377+ cmd := m.blurSelected()
378+ cmds = append(cmds, cmd)
379+ m.selectedItemInx = len(m.items) - 1
380+ cmd = m.focusSelected()
381+ cmds = append(cmds, cmd)
382+ m.ResetView()
383+ return tea.Batch(cmds...)
384+}
385+
386+func (m *model) ResetView() {
387+ m.renderedItems.Clear()
388+ m.renderedLines = []string{}
389+ m.offset = 0
390+ m.lastRenderedInx = -1
391+ m.finalHight = -1
392+ m.needsRerender = true
393+}
394+
395+func (m *model) goToTop() tea.Cmd {
396+ var cmds []tea.Cmd
397+ m.reverse = false
398+ cmd := m.blurSelected()
399+ cmds = append(cmds, cmd)
400+ m.selectedItemInx = 0
401+ cmd = m.focusSelected()
402+ cmds = append(cmds, cmd)
403+ m.ResetView()
404+ return tea.Batch(cmds...)
405+}
406+
407+func (m *model) focusSelected() tea.Cmd {
408+ if m.selectedItemInx == -1 {
409+ return nil
410+ }
411+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
412+ cmd := i.Focus()
413+ m.rerenderItem(m.selectedItemInx)
414+ return cmd
415+ }
416+ return nil
417+}
418+
419+func (m *model) blurSelected() tea.Cmd {
420+ if m.selectedItemInx == -1 {
421+ return nil
422+ }
423+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
424+ cmd := i.Blur()
425+ m.rerenderItem(m.selectedItemInx)
426+ return cmd
427+ }
428+ return nil
429+}
430+
431+func (m *model) rerenderItem(inx int) {
432+ if inx < 0 || len(m.renderedLines) == 0 {
433+ return
434+ }
435+ cached, ok := m.renderedItems.Load(inx)
436+ cachedItem, _ := cached.(renderedItem)
437+ if !ok {
438+ // No need to rerender
439+ return
440+ }
441+ rerenderedItem := m.items[inx].View()
442+ rerenderedLines := strings.Split(rerenderedItem, "\n")
443+ if m.gapSize > 0 && inx != len(m.items)-1 {
444+ for range m.gapSize {
445+ rerenderedLines = append(rerenderedLines, "")
446+ }
447+ }
448+ // check if lines are the same
449+ if slices.Equal(cachedItem.lines, rerenderedLines) {
450+ // No changes
451+ return
452+ }
453+ // check if the item is in the content
454+ start := cachedItem.start
455+ end := start + cachedItem.height
456+ totalLines := len(m.renderedLines)
457+ if m.reverse {
458+ end = totalLines - cachedItem.start
459+ start = end - cachedItem.height
460+ }
461+ if start <= totalLines && end <= totalLines {
462+ m.renderedLines = slices.Delete(m.renderedLines, start, end)
463+ m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
464+ }
465+ // TODO: if hight changed do something
466+ if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
467+ panic("not handled")
468+ }
469+ m.renderedItems.Store(inx, renderedItem{
470+ lines: rerenderedLines,
471+ start: cachedItem.start,
472+ height: len(rerenderedLines),
473+ })
474+ m.needsRerender = true
475+}
476+
477+func (m *model) increaseOffset(n int) {
478+ if m.finalHight > -1 {
479+ if m.offset < m.finalHight {
480+ m.offset += n
481+ if m.offset > m.finalHight {
482+ m.offset = m.finalHight
483+ }
484+ m.needsRerender = true
485+ }
486+ } else {
487+ m.offset += n
488+ m.needsRerender = true
489+ }
490+}
491+
492+func (m *model) decreaseOffset(n int) {
493+ if m.offset > 0 {
494+ m.offset -= n
495+ if m.offset < 0 {
496+ m.offset = 0
497+ }
498+ m.needsRerender = true
499+ }
500+}
501+
502+// UpdateItem implements List.
503+func (m *model) UpdateItem(inx int, item util.Model) {
504+ m.items[inx] = item
505+ m.rerenderItem(inx)
506+ m.needsRerender = true
507+}
508+
509+// GetSize implements List.
510+func (m *model) GetSize() (int, int) {
511+ return m.width, m.height
512+}
513+
514+// SetSize implements List.
515+func (m *model) SetSize(width int, height int) tea.Cmd {
516+ if m.width == width && m.height == height {
517+ return nil
518+ }
519+ if m.height != height {
520+ m.finalHight = -1
521+ m.height = height
522+ }
523+ m.width = width
524+ m.ResetView()
525+ return m.setItemsSize()
526+}
527+
528+func (m *model) setItemsSize() tea.Cmd {
529+ var cmds []tea.Cmd
530+ width := m.width
531+ if m.padding != nil {
532+ if len(m.padding) == 1 {
533+ width -= m.padding[0] * 2
534+ } else if len(m.padding) == 2 || len(m.padding) == 3 {
535+ width -= m.padding[1] * 2
536+ } else if len(m.padding) == 4 {
537+ width -= m.padding[1] + m.padding[3]
538+ }
539+ }
540+ for _, item := range m.items {
541+ if i, ok := item.(layout.Sizeable); ok {
542+ cmd := i.SetSize(width, 0) // height is not limited
543+ cmds = append(cmds, cmd)
544+ }
545+ }
546+ return tea.Batch(cmds...)
547+}
548+
549+func (m *model) listHeight() int {
550+ height := m.height
551+ if m.padding != nil {
552+ if len(m.padding) == 1 {
553+ height -= m.padding[0] * 2
554+ } else if len(m.padding) == 2 {
555+ height -= m.padding[1] * 2
556+ } else if len(m.padding) == 3 {
557+ height -= m.padding[0] + m.padding[2]
558+ } else if len(m.padding) == 4 {
559+ height -= m.padding[0] + m.padding[2]
560+ }
561+ }
562+ return height
563+}
564+
565+// AppendItem implements List.
566+func (m *model) AppendItem(item util.Model) {
567+ m.items = append(m.items, item)
568+ m.goToBottom()
569+ m.needsRerender = true
570+}
571+
572+// DeleteItem implements List.
573+func (m *model) DeleteItem(i int) {
574+ m.items = slices.Delete(m.items, i, i+1)
575+ m.renderedItems.Delete(i)
576+ m.needsRerender = true
577+}
578+
579+// PrependItem implements List.
580+func (m *model) PrependItem(item util.Model) {
581+ m.items = append([]util.Model{item}, m.items...)
582+ // update the indices of the rendered items
583+ newRenderedItems := make(map[int]renderedItem)
584+ m.renderedItems.Range(func(key any, value any) bool {
585+ keyInt := key.(int)
586+ renderedItem := value.(renderedItem)
587+ newKey := keyInt + 1
588+ newRenderedItems[newKey] = renderedItem
589+ return false
590+ })
591+ m.renderedItems.Clear()
592+ for k, v := range newRenderedItems {
593+ m.renderedItems.Store(k, v)
594+ }
595+ m.goToTop()
596+ m.needsRerender = true
597+}
598+
599+func (m *model) setReverse(reverse bool) {
600+ if reverse {
601+ m.goToBottom()
602+ } else {
603+ m.goToTop()
604+ }
605+}
606+
607+// SetItems implements List.
608+func (m *model) SetItems(items []util.Model) tea.Cmd {
609+ m.items = items
610+ var cmds []tea.Cmd
611+ cmd := m.setItemsSize()
612+ cmds = append(cmds, cmd)
613+ if m.reverse {
614+ m.selectedItemInx = len(m.items) - 1
615+ cmd := m.focusSelected()
616+ cmds = append(cmds, cmd)
617+ } else {
618+ m.selectedItemInx = 0
619+ cmd := m.focusSelected()
620+ cmds = append(cmds, cmd)
621+ }
622+ m.needsRerender = true
623+ m.ResetView()
624+ return tea.Batch(cmds...)
625+}
internal/tui/page/chat.go+1−1
@@ -217,7 +217,7 @@ func NewChatPage(app *app.App) util.Model {
217217 completionDialog := dialog.NewCompletionDialogCmp(cg)
218218
219219 messagesContainer := layout.NewContainer(
220− chat.NewMessagesCmp(app),
220+ chat.NewMessagesListCmp(app),
221221 layout.WithPadding(1, 1, 0, 1),
222222 )
223223 editorContainer := layout.NewContainer(
internal/tui/styles/markdown.go+1−3
@@ -33,9 +33,7 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
3333 return ansi.StyleConfig{
3434 Document: ansi.StyleBlock{
3535 StylePrimitive: ansi.StylePrimitive{
36− BlockPrefix: "",
37− BlockSuffix: "",
38− Color: stringPtr(colorToString(t.MarkdownText())),
36+ Color: stringPtr(colorToString(t.MarkdownText())),
3937 },
4038 Margin: uintPtr(defaultMargin),
4139 },
internal/tui/theme/opencode.go+4−3
@@ -62,8 +62,9 @@ func NewOpenCodeDarkTheme() *OpenCodeTheme {
6262 theme.DiffRemovedColor = lipgloss.Color("#7C4444")
6363 theme.DiffContextColor = lipgloss.Color("#a0a0a0")
6464 theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
65− theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA")
66− theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD")
65+ // TODO: change these colors to be what we want
66+ theme.DiffHighlightAddedColor = lipgloss.Color("#256125")
67+ theme.DiffHighlightRemovedColor = lipgloss.Color("#612726")
6768 theme.DiffAddedBgColor = lipgloss.Color("#303A30")
6869 theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
6970 theme.DiffContextBgColor = lipgloss.Color(darkBackground)
@@ -195,4 +196,4 @@ func init() {
195196 // Register the OpenCode themes with the theme manager
196197 RegisterTheme("opencode-dark", NewOpenCodeDarkTheme())
197198 RegisterTheme("opencode-light", NewOpenCodeLightTheme())
198−}
199+}