on main help sign in
cleanup old components
4a74863Kujtim Hoxha
internal/diff/diff.go+18−19
@@ -12,7 +12,7 @@ import (
12 "github.com/charmbracelet/x/ansi"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/opencode-ai/opencode/internal/config"
13 "github.com/opencode-ai/opencode/internal/config"
14 "github.com/opencode-ai/opencode/internal/highlight"
14 "github.com/opencode-ai/opencode/internal/highlight"
15− "github.com/opencode-ai/opencode/internal/tui/theme"
15+ "github.com/opencode-ai/opencode/internal/tui/styles"
16 "github.com/sergi/go-diff/diffmatchpatch"
16 "github.com/sergi/go-diff/diffmatchpatch"
17)
17)
18
18
@@ -329,12 +329,11 @@ func highlightLine(fileName string, line string, bg color.Color) string {
329}
329}
330
330
331// createStyles generates the lipgloss styles needed for rendering diffs
331// createStyles generates the lipgloss styles needed for rendering diffs
332−func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
332+func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
333− removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
333+ removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg)
334− addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
334+ addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg)
335− contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
335+ contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
336− lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
336+ lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber)
337−
338 return
337 return
339}
338}
340
339
@@ -446,10 +445,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
446
445
447// renderLeftColumn formats the left side of a side-by-side diff
446// renderLeftColumn formats the left side of a side-by-side diff
448func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
447func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
449− t := theme.CurrentTheme()
448+ t := styles.CurrentTheme()
450
449
451 if dl == nil {
450 if dl == nil {
452− contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
451+ contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg)
453 return contextLineStyle.Width(colWidth).Render("")
452 return contextLineStyle.Width(colWidth).Render("")
454 }
453 }
455
454
@@ -460,9 +459,9 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
460 var bgStyle lipgloss.Style
459 var bgStyle lipgloss.Style
461 switch dl.Kind {
460 switch dl.Kind {
462 case LineRemoved:
461 case LineRemoved:
463− marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
462+ marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-")
464 bgStyle = removedLineStyle
463 bgStyle = removedLineStyle
465− lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
464+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg)
466 case LineAdded:
465 case LineAdded:
467 marker = "?"
466 marker = "?"
468 bgStyle = contextLineStyle
467 bgStyle = contextLineStyle
@@ -485,7 +484,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
485
484
486 // Apply intra-line highlighting for removed lines
485 // Apply intra-line highlighting for removed lines
487 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
486 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
488− content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
487+ content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved)
489 }
488 }
490
489
491 // Add a padding space for removed lines
490 // Add a padding space for removed lines
@@ -499,17 +498,17 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
499 ansi.Truncate(
498 ansi.Truncate(
500 lineText,
499 lineText,
501 colWidth,
500 colWidth,
502− lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
501+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
503 ),
502 ),
504 )
503 )
505}
504}
506
505
507// renderRightColumn formats the right side of a side-by-side diff
506// renderRightColumn formats the right side of a side-by-side diff
508func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
507func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
509− t := theme.CurrentTheme()
508+ t := styles.CurrentTheme()
510
509
511 if dl == nil {
510 if dl == nil {
512− contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
511+ contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
513 return contextLineStyle.Width(colWidth).Render("")
512 return contextLineStyle.Width(colWidth).Render("")
514 }
513 }
515
514
@@ -520,9 +519,9 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
520 var bgStyle lipgloss.Style
519 var bgStyle lipgloss.Style
521 switch dl.Kind {
520 switch dl.Kind {
522 case LineAdded:
521 case LineAdded:
523− marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
522+ marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+")
524 bgStyle = addedLineStyle
523 bgStyle = addedLineStyle
525− lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
524+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg)
526 case LineRemoved:
525 case LineRemoved:
527 marker = "?"
526 marker = "?"
528 bgStyle = contextLineStyle
527 bgStyle = contextLineStyle
@@ -545,7 +544,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
545
544
546 // Apply intra-line highlighting for added lines
545 // Apply intra-line highlighting for added lines
547 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
546 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
548− content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
547+ content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded)
549 }
548 }
550
549
551 // Add a padding space for added lines
550 // Add a padding space for added lines
@@ -559,7 +558,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
559 ansi.Truncate(
558 ansi.Truncate(
560 lineText,
559 lineText,
561 colWidth,
560 colWidth,
562− lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
561+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
563 ),
562 ),
564 )
563 )
565}
564}
internal/highlight/highlight.go+4−171
@@ -4,18 +4,15 @@ import (
4 "bytes"
4 "bytes"
5 "fmt"
5 "fmt"
6 "image/color"
6 "image/color"
7− "strings"
8
7
9 "github.com/alecthomas/chroma/v2"
8 "github.com/alecthomas/chroma/v2"
10 "github.com/alecthomas/chroma/v2/formatters"
9 "github.com/alecthomas/chroma/v2/formatters"
11 "github.com/alecthomas/chroma/v2/lexers"
10 "github.com/alecthomas/chroma/v2/lexers"
12− "github.com/alecthomas/chroma/v2/styles"
11+ chromaStyles "github.com/alecthomas/chroma/v2/styles"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
12+ "github.com/opencode-ai/opencode/internal/tui/styles"
14)
13)
15
14
16func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
15func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
17− t := theme.CurrentTheme()
18−
19 // Determine the language lexer to use
16 // Determine the language lexer to use
20 l := lexers.Match(fileName)
17 l := lexers.Match(fileName)
21 if l == nil {
18 if l == nil {
@@ -32,171 +29,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
32 f = formatters.Fallback
29 f = formatters.Fallback
33 }
30 }
34
31
35− // Dynamic theme based on current theme values
32+ style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
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
33
201 // Modify the style to use the provided background
34 // Modify the style to use the provided background
202 s, err := style.Builder().Transform(
35 s, err := style.Builder().Transform(
@@ -207,7 +40,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
207 },
40 },
208 ).Build()
41 ).Build()
209 if err != nil {
42 if err != nil {
210− s = styles.Fallback
43+ s = chromaStyles.Fallback
211 }
44 }
212
45
213 // Tokenize and format
46 // Tokenize and format
internal/tui/components/anim/anim.go+11−7
@@ -1,6 +1,7 @@
1package anim
1package anim
2
2
3import (
3import (
4+ "fmt"
4 "image/color"
5 "image/color"
5 "math/rand"
6 "math/rand"
6 "strings"
7 "strings"
@@ -12,7 +13,6 @@ import (
12 "github.com/google/uuid"
13 "github.com/google/uuid"
13 "github.com/lucasb-eyer/go-colorful"
14 "github.com/lucasb-eyer/go-colorful"
14 "github.com/opencode-ai/opencode/internal/tui/styles"
15 "github.com/opencode-ai/opencode/internal/tui/styles"
15− "github.com/opencode-ai/opencode/internal/tui/theme"
16 "github.com/opencode-ai/opencode/internal/tui/util"
16 "github.com/opencode-ai/opencode/internal/tui/util"
17)
17)
18
18
@@ -240,7 +240,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
240
240
241// View renders the animation.
241// View renders the animation.
242func (a anim) View() tea.View {
242func (a anim) View() tea.View {
243− t := theme.CurrentTheme()
243+ t := styles.CurrentTheme()
244 var b strings.Builder
244 var b strings.Builder
245
245
246 for i, c := range a.cyclingChars {
246 for i, c := range a.cyclingChars {
@@ -252,8 +252,7 @@ func (a anim) View() tea.View {
252 }
252 }
253
253
254 if len(a.labelChars) > 1 {
254 if len(a.labelChars) > 1 {
255− textStyle := styles.BaseStyle().
255+ textStyle := t.S().Text
256− Foreground(t.Text())
257 for _, c := range a.labelChars {
256 for _, c := range a.labelChars {
258 b.WriteString(
257 b.WriteString(
259 textStyle.Render(string(c.currentValue)),
258 textStyle.Render(string(c.currentValue)),
@@ -265,10 +264,15 @@ func (a anim) View() tea.View {
265 return tea.NewView(b.String())
264 return tea.NewView(b.String())
266}
265}
267
266
267+func GetColor(c color.Color) string {
268+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
269+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
270+}
271+
268func makeGradientRamp(length int) []color.Color {
272func makeGradientRamp(length int) []color.Color {
269− t := theme.CurrentTheme()
273+ t := styles.CurrentTheme()
270− startColor := theme.GetColor(t.Primary())
274+ startColor := GetColor(t.Primary)
271− endColor := theme.GetColor(t.Secondary())
275+ endColor := GetColor(t.Secondary)
272 var (
276 var (
273 c = make([]color.Color, length)
277 c = make([]color.Color, length)
274 start, _ = colorful.Hex(startColor)
278 start, _ = colorful.Hex(startColor)
internal/tui/components/chat/chat.go+450−88
@@ -1,17 +1,22 @@
1package chat
1package chat
2
2
3import (
3import (
4− "fmt"
4+ "context"
5− "sort"
5+ "time"
6
6
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/charmbracelet/lipgloss/v2"
8− "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/opencode-ai/opencode/internal/app"
11+ "github.com/opencode-ai/opencode/internal/llm/agent"
9 "github.com/opencode-ai/opencode/internal/message"
12 "github.com/opencode-ai/opencode/internal/message"
13+ "github.com/opencode-ai/opencode/internal/pubsub"
10 "github.com/opencode-ai/opencode/internal/session"
14 "github.com/opencode-ai/opencode/internal/session"
11− "github.com/opencode-ai/opencode/internal/tui/components/logo"
15+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
12− "github.com/opencode-ai/opencode/internal/tui/styles"
16+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
17+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
14− "github.com/opencode-ai/opencode/internal/version"
18+ "github.com/opencode-ai/opencode/internal/tui/layout"
19+ "github.com/opencode-ai/opencode/internal/tui/util"
15)
20)
16
21
17type SendMsg struct {
22type SendMsg struct {
@@ -23,113 +28,470 @@ type SessionSelectedMsg = session.Session
23
28
24type SessionClearedMsg struct{}
29type SessionClearedMsg struct{}
25
30
26−type EditorFocusMsg bool
31+const (
32+ NotFound = -1
33+)
34+
35+// MessageListCmp represents a component that displays a list of chat messages
36+// with support for real-time updates and session management.
37+type MessageListCmp interface {
38+ util.Model
39+ layout.Sizeable
40+}
41+
42+// messageListCmp implements MessageListCmp, providing a virtualized list
43+// of chat messages with support for tool calls, real-time updates, and
44+// session switching.
45+type messageListCmp struct {
46+ app *app.App
47+ width, height int
48+ session session.Session
49+ listCmp list.ListModel
50+
51+ lastUserMessageTime int64
52+}
53+
54+// NewMessagesListCmp creates a new message list component with custom keybindings
55+// and reverse ordering (newest messages at bottom).
56+func NewMessagesListCmp(app *app.App) MessageListCmp {
57+ defaultKeymaps := list.DefaultKeyMap()
58+ defaultKeymaps.Up.SetEnabled(false)
59+ defaultKeymaps.Down.SetEnabled(false)
60+ defaultKeymaps.NDown = key.NewBinding(
61+ key.WithKeys("ctrl+j"),
62+ )
63+ defaultKeymaps.NUp = key.NewBinding(
64+ key.WithKeys("ctrl+k"),
65+ )
66+ defaultKeymaps.Home = key.NewBinding(
67+ key.WithKeys("ctrl+shift+up"),
68+ )
69+ defaultKeymaps.End = key.NewBinding(
70+ key.WithKeys("ctrl+shift+down"),
71+ )
72+ return &messageListCmp{
73+ app: app,
74+ listCmp: list.New(
75+ list.WithGapSize(1),
76+ list.WithReverse(true),
77+ list.WithKeyMap(defaultKeymaps),
78+ ),
79+ }
80+}
81+
82+// Init initializes the component (no initialization needed).
83+func (m *messageListCmp) Init() tea.Cmd {
84+ return nil
85+}
86+
87+// Update handles incoming messages and updates the component state.
88+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89+ switch msg := msg.(type) {
90+ case dialog.ThemeChangedMsg:
91+ m.listCmp.ResetView()
92+ return m, nil
93+ case SessionSelectedMsg:
94+ if msg.ID != m.session.ID {
95+ cmd := m.SetSession(msg)
96+ return m, cmd
97+ }
98+ return m, nil
99+ case SessionClearedMsg:
100+ m.session = session.Session{}
101+ return m, m.listCmp.SetItems([]util.Model{})
102+
103+ case pubsub.Event[message.Message]:
104+ cmd := m.handleMessageEvent(msg)
105+ return m, cmd
106+ default:
107+ var cmds []tea.Cmd
108+ u, cmd := m.listCmp.Update(msg)
109+ m.listCmp = u.(list.ListModel)
110+ cmds = append(cmds, cmd)
111+ return m, tea.Batch(cmds...)
112+ }
113+}
27
114
28−func header() string {
115+// View renders the message list or an initial screen if empty.
116+func (m *messageListCmp) View() tea.View {
117+ return tea.NewView(
29− return lipgloss.JoinVertical(
118+ lipgloss.JoinVertical(
30− lipgloss.Top,
119+ lipgloss.Left,
31− logoBlock(),
120+ m.listCmp.View().String(),
32− repo(),
121+ ),
33− "",
122+ )
34− cwd(),
123+}
124+
125+// handleChildSession handles messages from child sessions (agent tools).
126+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
127+ var cmds []tea.Cmd
128+ if len(event.Payload.ToolCalls()) == 0 {
129+ return nil
130+ }
131+ items := m.listCmp.Items()
132+ toolCallInx := NotFound
133+ var toolCall messages.ToolCallCmp
134+ for i := len(items) - 1; i >= 0; i-- {
135+ if msg, ok := items[i].(messages.ToolCallCmp); ok {
136+ if msg.GetToolCall().ID == event.Payload.SessionID {
137+ toolCallInx = i
138+ toolCall = msg
139+ }
140+ }
141+ }
142+ if toolCallInx == NotFound {
143+ return nil
144+ }
145+ nestedToolCalls := toolCall.GetNestedToolCalls()
146+ for _, tc := range event.Payload.ToolCalls() {
147+ found := false
148+ for existingInx, existingTC := range nestedToolCalls {
149+ if existingTC.GetToolCall().ID == tc.ID {
150+ nestedToolCalls[existingInx].SetToolCall(tc)
151+ found = true
152+ break
153+ }
154+ }
155+ if !found {
156+ nestedCall := messages.NewToolCallCmp(
157+ event.Payload.ID,
158+ tc,
159+ messages.WithToolCallNested(true),
160+ )
161+ cmds = append(cmds, nestedCall.Init())
162+ nestedToolCalls = append(
163+ nestedToolCalls,
164+ nestedCall,
165+ )
166+ }
167+ }
168+ toolCall.SetNestedToolCalls(nestedToolCalls)
169+ m.listCmp.UpdateItem(
170+ toolCallInx,
171+ toolCall,
35 )
172 )
173+ return tea.Batch(cmds...)
174+}
175+
176+// handleMessageEvent processes different types of message events (created/updated).
177+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
178+ switch event.Type {
179+ case pubsub.CreatedEvent:
180+ if event.Payload.SessionID != m.session.ID {
181+ return m.handleChildSession(event)
182+ }
183+ if m.messageExists(event.Payload.ID) {
184+ return nil
185+ }
186+ return m.handleNewMessage(event.Payload)
187+ case pubsub.UpdatedEvent:
188+ if event.Payload.SessionID != m.session.ID {
189+ return m.handleChildSession(event)
190+ }
191+ return m.handleUpdateAssistantMessage(event.Payload)
192+ }
193+ return nil
194+}
195+
196+// messageExists checks if a message with the given ID already exists in the list.
197+func (m *messageListCmp) messageExists(messageID string) bool {
198+ items := m.listCmp.Items()
199+ // Search backwards as new messages are more likely to be at the end
200+ for i := len(items) - 1; i >= 0; i-- {
201+ if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
202+ return true
203+ }
204+ }
205+ return false
206+}
207+
208+// handleNewMessage routes new messages to appropriate handlers based on role.
209+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
210+ switch msg.Role {
211+ case message.User:
212+ return m.handleNewUserMessage(msg)
213+ case message.Assistant:
214+ return m.handleNewAssistantMessage(msg)
215+ case message.Tool:
216+ return m.handleToolMessage(msg)
217+ }
218+ return nil
219+}
220+
221+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
222+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
223+ m.lastUserMessageTime = msg.CreatedAt
224+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
225+}
226+
227+// handleToolMessage updates existing tool calls with their results.
228+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
229+ items := m.listCmp.Items()
230+ for _, tr := range msg.ToolResults() {
231+ if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
232+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
233+ toolCall.SetToolResult(tr)
234+ m.listCmp.UpdateItem(toolCallIndex, toolCall)
235+ }
236+ }
237+ return nil
238+}
239+
240+// findToolCallByID searches for a tool call with the specified ID.
241+// Returns the index if found, NotFound otherwise.
242+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
243+ // Search backwards as tool calls are more likely to be recent
244+ for i := len(items) - 1; i >= 0; i-- {
245+ if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
246+ return i
247+ }
248+ }
249+ return NotFound
36}
250}
37
251
38−func lspsConfigured() string {
252+// handleUpdateAssistantMessage processes updates to assistant messages,
39− cfg := config.Get()
253+// managing both message content and associated tool calls.
40− title := "LSP Configuration"
254+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
255+ var cmds []tea.Cmd
256+ items := m.listCmp.Items()
41
257
42− t := theme.CurrentTheme()
258+ // Find existing assistant message and tool calls for this message
43− baseStyle := styles.BaseStyle()
259+ assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
260+
261+ // Handle assistant message content
262+ if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
263+ cmds = append(cmds, cmd)
264+ }
265+
266+ // Handle tool calls
267+ if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
268+ cmds = append(cmds, cmd)
269+ }
270+
271+ return tea.Batch(cmds...)
272+}
44
273
45− lsps := baseStyle.
274+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
46− Foreground(t.Primary()).
275+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
47− Bold(true).
276+ assistantIndex := NotFound
48− Render(title)
277+ toolCalls := make(map[int]messages.ToolCallCmp)
49
278
50− // Get LSP names and sort them for consistent ordering
279+ // Search backwards as messages are more likely to be at the end
51− var lspNames []string
280+ for i := len(items) - 1; i >= 0; i-- {
52− for name := range cfg.LSP {
281+ item := items[i]
53− lspNames = append(lspNames, name)
282+ if asMsg, ok := item.(messages.MessageCmp); ok {
283+ if asMsg.GetMessage().ID == messageID {
284+ assistantIndex = i
285+ }
286+ } else if tc, ok := item.(messages.ToolCallCmp); ok {
287+ if tc.ParentMessageId() == messageID {
288+ toolCalls[i] = tc
289+ }
290+ }
54 }
291 }
55− sort.Strings(lspNames)
56
292
57− var lspViews []string
293+ return assistantIndex, toolCalls
58− for _, name := range lspNames {
294+}
59− lsp := cfg.LSP[name]
60− lspName := baseStyle.
61− Foreground(t.Text()).
62− Render(fmt.Sprintf("• %s", name))
63
295
64− cmd := lsp.Command
296+// updateAssistantMessageContent updates or removes the assistant message based on content.
297+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
298+ if assistantIndex == NotFound {
299+ return nil
300+ }
65
301
66− lspPath := baseStyle.
302+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
67− Foreground(t.TextMuted()).
303+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
68− Render(fmt.Sprintf(" (%s)", cmd))
69
304
70− lspViews = append(lspViews,
305+ if shouldShowMessage {
71− baseStyle.
306+ m.listCmp.UpdateItem(
72− Render(
307+ assistantIndex,
73− lipgloss.JoinHorizontal(
308+ messages.NewMessageCmp(
74− lipgloss.Left,
309+ msg,
75− lspName,
310+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
76− lspPath,
77− ),
78− ),
311+ ),
79 )
312 )
313+ } else if hasToolCallsOnly {
314+ m.listCmp.DeleteItem(assistantIndex)
315+ }
316+
317+ return nil
318+}
319+
320+// shouldShowAssistantMessage determines if an assistant message should be displayed.
321+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
322+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
323+}
324+
325+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
326+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
327+ var cmds []tea.Cmd
328+
329+ for _, tc := range msg.ToolCalls() {
330+ if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
331+ cmds = append(cmds, cmd)
332+ }
333+ }
334+
335+ return tea.Batch(cmds...)
336+}
337+
338+// updateOrAddToolCall updates an existing tool call or adds a new one.
339+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
340+ // Try to find existing tool call
341+ for index, existingTC := range existingToolCalls {
342+ if tc.ID == existingTC.GetToolCall().ID {
343+ existingTC.SetToolCall(tc)
344+ m.listCmp.UpdateItem(index, existingTC)
345+ return nil
346+ }
80 }
347 }
81
348
82− return baseStyle.
349+ // Add new tool call if not found
83− Render(
350+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
84− lipgloss.JoinVertical(
351+}
85− lipgloss.Left,
352+
86− lsps,
353+// handleNewAssistantMessage processes new assistant messages and their tool calls.
87− lipgloss.JoinVertical(
354+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
88− lipgloss.Left,
355+ var cmds []tea.Cmd
89− lspViews...,
356+
90− ),
357+ // Add assistant message if it should be displayed
358+ if m.shouldShowAssistantMessage(msg) {
359+ cmd := m.listCmp.AppendItem(
360+ messages.NewMessageCmp(
361+ msg,
362+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
91 ),
363 ),
92 )
364 )
365+ cmds = append(cmds, cmd)
366+ }
367+
368+ // Add tool calls
369+ for _, tc := range msg.ToolCalls() {
370+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
371+ cmds = append(cmds, cmd)
372+ }
373+
374+ return tea.Batch(cmds...)
375+}
376+
377+// SetSession loads and displays messages for a new session.
378+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
379+ if m.session.ID == session.ID {
380+ return nil
381+ }
382+
383+ m.session = session
384+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
385+ if err != nil {
386+ return util.ReportError(err)
387+ }
388+
389+ if len(sessionMessages) == 0 {
390+ return m.listCmp.SetItems([]util.Model{})
391+ }
392+
393+ // Initialize with first message timestamp
394+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
395+
396+ // Build tool result map for efficient lookup
397+ toolResultMap := m.buildToolResultMap(sessionMessages)
398+
399+ // Convert messages to UI components
400+ uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
401+
402+ return m.listCmp.SetItems(uiMessages)
93}
403}
94
404
95−func logoBlock() string {
405+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
96− t := theme.CurrentTheme()
406+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
97− return logo.Render(version.Version, true, logo.Opts{
407+ toolResultMap := make(map[string]message.ToolResult)
98− FieldColor: t.Secondary(),
408+ for _, msg := range messages {
99− TitleColorA: t.Primary(),
409+ for _, tr := range msg.ToolResults() {
100− TitleColorB: t.Secondary(),
410+ toolResultMap[tr.ToolCallID] = tr
101− CharmColor: t.Primary(),
411+ }
102− VersionColor: t.Secondary(),
412+ }
103− })
413+ return toolResultMap
104}
414}
105
415
106−func repo() string {
416+// convertMessagesToUI converts database messages to UI components.
107− repo := "https://github.com/opencode-ai/opencode"
417+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
108− t := theme.CurrentTheme()
418+ uiMessages := make([]util.Model, 0)
419+
420+ for _, msg := range sessionMessages {
421+ switch msg.Role {
422+ case message.User:
423+ m.lastUserMessageTime = msg.CreatedAt
424+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
425+ case message.Assistant:
426+ uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
427+ }
428+ }
429+
430+ return uiMessages
431+}
432+
433+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
434+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
435+ var uiMessages []util.Model
436+
437+ // Add assistant message if it should be displayed
438+ if m.shouldShowAssistantMessage(msg) {
439+ uiMessages = append(
440+ uiMessages,
441+ messages.NewMessageCmp(
442+ msg,
443+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
444+ ),
445+ )
446+ }
109
447
110− return styles.BaseStyle().
448+ // Add tool calls with their results and status
111− Foreground(t.TextMuted()).
449+ for _, tc := range msg.ToolCalls() {
112− Render(repo)
450+ options := m.buildToolCallOptions(tc, msg, toolResultMap)
451+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
452+ // If this tool call is the agent tool, fetch nested tool calls
453+ if tc.Name == agent.AgentToolName {
454+ nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
455+ nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
456+ nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
457+ for _, nestedMsg := range nestedUIMessages {
458+ if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
459+ toolCall.SetIsNested(true)
460+ nestedToolCalls = append(nestedToolCalls, toolCall)
461+ }
462+ }
463+ uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
464+ }
465+ }
466+
467+ return uiMessages
113}
468}
114
469
115−func cwd() string {
470+// buildToolCallOptions creates options for tool call components based on results and status.
116− cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
471+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
117− t := theme.CurrentTheme()
472+ var options []messages.ToolCallOption
473+
474+ // Add tool result if available
475+ if tr, ok := toolResultMap[tc.ID]; ok {
476+ options = append(options, messages.WithToolCallResult(tr))
477+ }
118
478
119− return styles.BaseStyle().
479+ // Add cancelled status if applicable
120− Foreground(t.TextMuted()).
480+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
121− Render(cwd)
481+ options = append(options, messages.WithToolCallCancelled())
482+ }
483+
484+ return options
122}
485}
123
486
124−func initialScreen() string {
487+// GetSize returns the current width and height of the component.
125− baseStyle := styles.BaseStyle()
488+func (m *messageListCmp) GetSize() (int, int) {
489+ return m.width, m.height
490+}
126
491
127− return baseStyle.Render(
492+// SetSize updates the component dimensions and propagates to the list component.
128− lipgloss.JoinVertical(
493+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
129− lipgloss.Top,
494+ m.width = width
130− header(),
495+ m.height = height - 1
131− "",
496+ return m.listCmp.SetSize(width, height-1)
132− lspsConfigured(),
133− ),
134− )
135}
497}
internal/tui/components/chat/list.godeleted−486
@@ -1,486 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "time"
6−
7− "github.com/charmbracelet/bubbles/v2/key"
8− tea "github.com/charmbracelet/bubbletea/v2"
9− "github.com/charmbracelet/lipgloss/v2"
10− "github.com/opencode-ai/opencode/internal/app"
11− "github.com/opencode-ai/opencode/internal/llm/agent"
12− "github.com/opencode-ai/opencode/internal/message"
13− "github.com/opencode-ai/opencode/internal/pubsub"
14− "github.com/opencode-ai/opencode/internal/session"
15− "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
16− "github.com/opencode-ai/opencode/internal/tui/components/core/list"
17− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
18− "github.com/opencode-ai/opencode/internal/tui/layout"
19− "github.com/opencode-ai/opencode/internal/tui/util"
20−)
21−
22−const (
23− NotFound = -1
24−)
25−
26−// MessageListCmp represents a component that displays a list of chat messages
27−// with support for real-time updates and session management.
28−type MessageListCmp interface {
29− util.Model
30− layout.Sizeable
31−}
32−
33−// messageListCmp implements MessageListCmp, providing a virtualized list
34−// of chat messages with support for tool calls, real-time updates, and
35−// session switching.
36−type messageListCmp struct {
37− app *app.App
38− width, height int
39− session session.Session
40− listCmp list.ListModel
41−
42− lastUserMessageTime int64
43−}
44−
45−// NewMessagesListCmp creates a new message list component with custom keybindings
46−// and reverse ordering (newest messages at bottom).
47−func NewMessagesListCmp(app *app.App) MessageListCmp {
48− defaultKeymaps := list.DefaultKeyMap()
49− defaultKeymaps.Up.SetEnabled(false)
50− defaultKeymaps.Down.SetEnabled(false)
51− defaultKeymaps.NDown = key.NewBinding(
52− key.WithKeys("ctrl+j"),
53− )
54− defaultKeymaps.NUp = key.NewBinding(
55− key.WithKeys("ctrl+k"),
56− )
57− defaultKeymaps.Home = key.NewBinding(
58− key.WithKeys("ctrl+shift+up"),
59− )
60− defaultKeymaps.End = key.NewBinding(
61− key.WithKeys("ctrl+shift+down"),
62− )
63− return &messageListCmp{
64− app: app,
65− listCmp: list.New(
66− list.WithGapSize(1),
67− list.WithReverse(true),
68− list.WithKeyMap(defaultKeymaps),
69− ),
70− }
71−}
72−
73−// Init initializes the component (no initialization needed).
74−func (m *messageListCmp) Init() tea.Cmd {
75− return nil
76−}
77−
78−// Update handles incoming messages and updates the component state.
79−func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80− switch msg := msg.(type) {
81− case dialog.ThemeChangedMsg:
82− m.listCmp.ResetView()
83− return m, nil
84− case SessionSelectedMsg:
85− if msg.ID != m.session.ID {
86− cmd := m.SetSession(msg)
87− return m, cmd
88− }
89− return m, nil
90− case SessionClearedMsg:
91− m.session = session.Session{}
92− return m, m.listCmp.SetItems([]util.Model{})
93−
94− case pubsub.Event[message.Message]:
95− cmd := m.handleMessageEvent(msg)
96− return m, cmd
97− default:
98− var cmds []tea.Cmd
99− u, cmd := m.listCmp.Update(msg)
100− m.listCmp = u.(list.ListModel)
101− cmds = append(cmds, cmd)
102− return m, tea.Batch(cmds...)
103− }
104−}
105−
106−// View renders the message list or an initial screen if empty.
107−func (m *messageListCmp) View() tea.View {
108− if len(m.listCmp.Items()) == 0 {
109− return tea.NewView(initialScreen())
110− }
111− return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String()))
112−}
113−
114−// handleChildSession handles messages from child sessions (agent tools).
115−func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
116− var cmds []tea.Cmd
117− if len(event.Payload.ToolCalls()) == 0 {
118− return nil
119− }
120− items := m.listCmp.Items()
121− toolCallInx := NotFound
122− var toolCall messages.ToolCallCmp
123− for i := len(items) - 1; i >= 0; i-- {
124− if msg, ok := items[i].(messages.ToolCallCmp); ok {
125− if msg.GetToolCall().ID == event.Payload.SessionID {
126− toolCallInx = i
127− toolCall = msg
128− }
129− }
130− }
131− if toolCallInx == NotFound {
132− return nil
133− }
134− nestedToolCalls := toolCall.GetNestedToolCalls()
135− for _, tc := range event.Payload.ToolCalls() {
136− found := false
137− for existingInx, existingTC := range nestedToolCalls {
138− if existingTC.GetToolCall().ID == tc.ID {
139− nestedToolCalls[existingInx].SetToolCall(tc)
140− found = true
141− break
142− }
143− }
144− if !found {
145− nestedCall := messages.NewToolCallCmp(
146− event.Payload.ID,
147− tc,
148− messages.WithToolCallNested(true),
149− )
150− cmds = append(cmds, nestedCall.Init())
151− nestedToolCalls = append(
152− nestedToolCalls,
153− nestedCall,
154− )
155− }
156− }
157− toolCall.SetNestedToolCalls(nestedToolCalls)
158− m.listCmp.UpdateItem(
159− toolCallInx,
160− toolCall,
161− )
162− return tea.Batch(cmds...)
163−}
164−
165−// handleMessageEvent processes different types of message events (created/updated).
166−func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
167− switch event.Type {
168− case pubsub.CreatedEvent:
169− if event.Payload.SessionID != m.session.ID {
170− return m.handleChildSession(event)
171− }
172− if m.messageExists(event.Payload.ID) {
173− return nil
174− }
175− return m.handleNewMessage(event.Payload)
176− case pubsub.UpdatedEvent:
177− if event.Payload.SessionID != m.session.ID {
178− return m.handleChildSession(event)
179− }
180− return m.handleUpdateAssistantMessage(event.Payload)
181− }
182− return nil
183−}
184−
185−// messageExists checks if a message with the given ID already exists in the list.
186−func (m *messageListCmp) messageExists(messageID string) bool {
187− items := m.listCmp.Items()
188− // Search backwards as new messages are more likely to be at the end
189− for i := len(items) - 1; i >= 0; i-- {
190− if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
191− return true
192− }
193− }
194− return false
195−}
196−
197−// handleNewMessage routes new messages to appropriate handlers based on role.
198−func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
199− switch msg.Role {
200− case message.User:
201− return m.handleNewUserMessage(msg)
202− case message.Assistant:
203− return m.handleNewAssistantMessage(msg)
204− case message.Tool:
205− return m.handleToolMessage(msg)
206− }
207− return nil
208−}
209−
210−// handleNewUserMessage adds a new user message to the list and updates the timestamp.
211−func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
212− m.lastUserMessageTime = msg.CreatedAt
213− return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
214−}
215−
216−// handleToolMessage updates existing tool calls with their results.
217−func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
218− items := m.listCmp.Items()
219− for _, tr := range msg.ToolResults() {
220− if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
221− toolCall := items[toolCallIndex].(messages.ToolCallCmp)
222− toolCall.SetToolResult(tr)
223− m.listCmp.UpdateItem(toolCallIndex, toolCall)
224− }
225− }
226− return nil
227−}
228−
229−// findToolCallByID searches for a tool call with the specified ID.
230−// Returns the index if found, NotFound otherwise.
231−func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
232− // Search backwards as tool calls are more likely to be recent
233− for i := len(items) - 1; i >= 0; i-- {
234− if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
235− return i
236− }
237− }
238− return NotFound
239−}
240−
241−// handleUpdateAssistantMessage processes updates to assistant messages,
242−// managing both message content and associated tool calls.
243−func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
244− var cmds []tea.Cmd
245− items := m.listCmp.Items()
246−
247− // Find existing assistant message and tool calls for this message
248− assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
249−
250− // Handle assistant message content
251− if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
252− cmds = append(cmds, cmd)
253− }
254−
255− // Handle tool calls
256− if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
257− cmds = append(cmds, cmd)
258− }
259−
260− return tea.Batch(cmds...)
261−}
262−
263−// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
264−func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
265− assistantIndex := NotFound
266− toolCalls := make(map[int]messages.ToolCallCmp)
267−
268− // Search backwards as messages are more likely to be at the end
269− for i := len(items) - 1; i >= 0; i-- {
270− item := items[i]
271− if asMsg, ok := item.(messages.MessageCmp); ok {
272− if asMsg.GetMessage().ID == messageID {
273− assistantIndex = i
274− }
275− } else if tc, ok := item.(messages.ToolCallCmp); ok {
276− if tc.ParentMessageId() == messageID {
277− toolCalls[i] = tc
278− }
279− }
280− }
281−
282− return assistantIndex, toolCalls
283−}
284−
285−// updateAssistantMessageContent updates or removes the assistant message based on content.
286−func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
287− if assistantIndex == NotFound {
288− return nil
289− }
290−
291− shouldShowMessage := m.shouldShowAssistantMessage(msg)
292− hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
293−
294− if shouldShowMessage {
295− m.listCmp.UpdateItem(
296− assistantIndex,
297− messages.NewMessageCmp(
298− msg,
299− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
300− ),
301− )
302− } else if hasToolCallsOnly {
303− m.listCmp.DeleteItem(assistantIndex)
304− }
305−
306− return nil
307−}
308−
309−// shouldShowAssistantMessage determines if an assistant message should be displayed.
310−func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
311− return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
312−}
313−
314−// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
315−func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
316− var cmds []tea.Cmd
317−
318− for _, tc := range msg.ToolCalls() {
319− if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
320− cmds = append(cmds, cmd)
321− }
322− }
323−
324− return tea.Batch(cmds...)
325−}
326−
327−// updateOrAddToolCall updates an existing tool call or adds a new one.
328−func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
329− // Try to find existing tool call
330− for index, existingTC := range existingToolCalls {
331− if tc.ID == existingTC.GetToolCall().ID {
332− existingTC.SetToolCall(tc)
333− m.listCmp.UpdateItem(index, existingTC)
334− return nil
335− }
336− }
337−
338− // Add new tool call if not found
339− return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
340−}
341−
342−// handleNewAssistantMessage processes new assistant messages and their tool calls.
343−func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
344− var cmds []tea.Cmd
345−
346− // Add assistant message if it should be displayed
347− if m.shouldShowAssistantMessage(msg) {
348− cmd := m.listCmp.AppendItem(
349− messages.NewMessageCmp(
350− msg,
351− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
352− ),
353− )
354− cmds = append(cmds, cmd)
355− }
356−
357− // Add tool calls
358− for _, tc := range msg.ToolCalls() {
359− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
360− cmds = append(cmds, cmd)
361− }
362−
363− return tea.Batch(cmds...)
364−}
365−
366−// SetSession loads and displays messages for a new session.
367−func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
368− if m.session.ID == session.ID {
369− return nil
370− }
371−
372− m.session = session
373− sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
374− if err != nil {
375− return util.ReportError(err)
376− }
377−
378− if len(sessionMessages) == 0 {
379− return m.listCmp.SetItems([]util.Model{})
380− }
381−
382− // Initialize with first message timestamp
383− m.lastUserMessageTime = sessionMessages[0].CreatedAt
384−
385− // Build tool result map for efficient lookup
386− toolResultMap := m.buildToolResultMap(sessionMessages)
387−
388− // Convert messages to UI components
389− uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
390−
391− return m.listCmp.SetItems(uiMessages)
392−}
393−
394−// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
395−func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
396− toolResultMap := make(map[string]message.ToolResult)
397− for _, msg := range messages {
398− for _, tr := range msg.ToolResults() {
399− toolResultMap[tr.ToolCallID] = tr
400− }
401− }
402− return toolResultMap
403−}
404−
405−// convertMessagesToUI converts database messages to UI components.
406−func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
407− uiMessages := make([]util.Model, 0)
408−
409− for _, msg := range sessionMessages {
410− switch msg.Role {
411− case message.User:
412− m.lastUserMessageTime = msg.CreatedAt
413− uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
414− case message.Assistant:
415− uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
416− }
417− }
418−
419− return uiMessages
420−}
421−
422−// convertAssistantMessage converts an assistant message and its tool calls to UI components.
423−func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
424− var uiMessages []util.Model
425−
426− // Add assistant message if it should be displayed
427− if m.shouldShowAssistantMessage(msg) {
428− uiMessages = append(
429− uiMessages,
430− messages.NewMessageCmp(
431− msg,
432− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
433− ),
434− )
435− }
436−
437− // Add tool calls with their results and status
438− for _, tc := range msg.ToolCalls() {
439− options := m.buildToolCallOptions(tc, msg, toolResultMap)
440− uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
441− // If this tool call is the agent tool, fetch nested tool calls
442− if tc.Name == agent.AgentToolName {
443− nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
444− nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
445− nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
446− for _, nestedMsg := range nestedUIMessages {
447− if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
448− toolCall.SetIsNested(true)
449− nestedToolCalls = append(nestedToolCalls, toolCall)
450− }
451− }
452− uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
453− }
454− }
455−
456− return uiMessages
457−}
458−
459−// buildToolCallOptions creates options for tool call components based on results and status.
460−func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
461− var options []messages.ToolCallOption
462−
463− // Add tool result if available
464− if tr, ok := toolResultMap[tc.ID]; ok {
465− options = append(options, messages.WithToolCallResult(tr))
466− }
467−
468− // Add cancelled status if applicable
469− if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
470− options = append(options, messages.WithToolCallCancelled())
471− }
472−
473− return options
474−}
475−
476−// GetSize returns the current width and height of the component.
477−func (m *messageListCmp) GetSize() (int, int) {
478− return m.width, m.height
479−}
480−
481−// SetSize updates the component dimensions and propagates to the list component.
482−func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
483− m.width = width
484− m.height = height - 1
485− return m.listCmp.SetSize(width, height-1)
486−}
internal/tui/components/chat/sidebar.godeleted−381
@@ -1,381 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "fmt"
6− "sort"
7− "strings"
8−
9− tea "github.com/charmbracelet/bubbletea/v2"
10− "github.com/charmbracelet/lipgloss/v2"
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/diff"
13− "github.com/opencode-ai/opencode/internal/history"
14− "github.com/opencode-ai/opencode/internal/pubsub"
15− "github.com/opencode-ai/opencode/internal/session"
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 sidebarCmp struct {
22− width, height int
23− session session.Session
24− history history.Service
25− modFiles map[string]struct {
26− additions int
27− removals int
28− }
29−}
30−
31−func (m *sidebarCmp) Init() tea.Cmd {
32− if m.history != nil {
33− ctx := context.Background()
34− // Subscribe to file events
35− filesCh := m.history.Subscribe(ctx)
36−
37− // Initialize the modified files map
38− m.modFiles = make(map[string]struct {
39− additions int
40− removals int
41− })
42−
43− // Load initial files and calculate diffs
44− m.loadModifiedFiles(ctx)
45−
46− // Return a command that will send file events to the Update method
47− return func() tea.Msg {
48− return <-filesCh
49− }
50− }
51− return nil
52−}
53−
54−func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55− switch msg := msg.(type) {
56− case SessionSelectedMsg:
57− if msg.ID != m.session.ID {
58− m.session = msg
59− ctx := context.Background()
60− m.loadModifiedFiles(ctx)
61− }
62− case pubsub.Event[session.Session]:
63− if msg.Type == pubsub.UpdatedEvent {
64− if m.session.ID == msg.Payload.ID {
65− m.session = msg.Payload
66− }
67− }
68− case pubsub.Event[history.File]:
69− if msg.Payload.SessionID == m.session.ID {
70− // Process the individual file change instead of reloading all files
71− ctx := context.Background()
72− m.processFileChanges(ctx, msg.Payload)
73−
74− // Return a command to continue receiving events
75− return m, func() tea.Msg {
76− ctx := context.Background()
77− filesCh := m.history.Subscribe(ctx)
78− return <-filesCh
79− }
80− }
81− }
82− return m, nil
83−}
84−
85−func (m *sidebarCmp) View() tea.View {
86− baseStyle := styles.BaseStyle()
87−
88− return tea.NewView(
89− baseStyle.
90− Width(m.width).
91− PaddingLeft(4).
92− PaddingRight(2).
93− Height(m.height - 1).
94− Render(
95− lipgloss.JoinVertical(
96− lipgloss.Top,
97− header(),
98− " ",
99− m.sessionSection(),
100− " ",
101− lspsConfigured(),
102− " ",
103− m.modifiedFiles(),
104− ),
105− ),
106− )
107−}
108−
109−func (m *sidebarCmp) sessionSection() string {
110− t := theme.CurrentTheme()
111− baseStyle := styles.BaseStyle()
112−
113− sessionKey := baseStyle.
114− Foreground(t.Primary()).
115− Bold(true).
116− Render("Session")
117−
118− sessionValue := baseStyle.
119− Foreground(t.Text()).
120− Width(m.width - lipgloss.Width(sessionKey)).
121− Render(fmt.Sprintf(": %s", m.session.Title))
122−
123− return lipgloss.JoinHorizontal(
124− lipgloss.Left,
125− sessionKey,
126− sessionValue,
127− )
128−}
129−
130−func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
131− t := theme.CurrentTheme()
132− baseStyle := styles.BaseStyle()
133−
134− stats := ""
135− if additions > 0 && removals > 0 {
136− additionsStr := baseStyle.
137− Foreground(t.Success()).
138− PaddingLeft(1).
139− Render(fmt.Sprintf("+%d", additions))
140−
141− removalsStr := baseStyle.
142− Foreground(t.Error()).
143− PaddingLeft(1).
144− Render(fmt.Sprintf("-%d", removals))
145−
146− content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
147− stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
148− } else if additions > 0 {
149− additionsStr := fmt.Sprintf(" %s", baseStyle.
150− PaddingLeft(1).
151− Foreground(t.Success()).
152− Render(fmt.Sprintf("+%d", additions)))
153− stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
154− } else if removals > 0 {
155− removalsStr := fmt.Sprintf(" %s", baseStyle.
156− PaddingLeft(1).
157− Foreground(t.Error()).
158− Render(fmt.Sprintf("-%d", removals)))
159− stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
160− }
161−
162− filePathStr := baseStyle.Render(filePath)
163−
164− return baseStyle.
165− Width(m.width).
166− Render(
167− lipgloss.JoinHorizontal(
168− lipgloss.Left,
169− filePathStr,
170− stats,
171− ),
172− )
173−}
174−
175−func (m *sidebarCmp) modifiedFiles() string {
176− t := theme.CurrentTheme()
177− baseStyle := styles.BaseStyle()
178−
179− modifiedFiles := baseStyle.
180− Width(m.width).
181− Foreground(t.Primary()).
182− Bold(true).
183− Render("Modified Files:")
184−
185− // If no modified files, show a placeholder message
186− if len(m.modFiles) == 0 {
187− message := "No modified files"
188− remainingWidth := m.width - lipgloss.Width(message)
189− if remainingWidth > 0 {
190− message += strings.Repeat(" ", remainingWidth)
191− }
192− return baseStyle.
193− Width(m.width).
194− Render(
195− lipgloss.JoinVertical(
196− lipgloss.Top,
197− modifiedFiles,
198− baseStyle.Foreground(t.TextMuted()).Render(message),
199− ),
200− )
201− }
202−
203− // Sort file paths alphabetically for consistent ordering
204− var paths []string
205− for path := range m.modFiles {
206− paths = append(paths, path)
207− }
208− sort.Strings(paths)
209−
210− // Create views for each file in sorted order
211− var fileViews []string
212− for _, path := range paths {
213− stats := m.modFiles[path]
214− fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
215− }
216−
217− return baseStyle.
218− Width(m.width).
219− Render(
220− lipgloss.JoinVertical(
221− lipgloss.Top,
222− modifiedFiles,
223− lipgloss.JoinVertical(
224− lipgloss.Left,
225− fileViews...,
226− ),
227− ),
228− )
229−}
230−
231−func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
232− m.width = width
233− m.height = height
234− return nil
235−}
236−
237−func (m *sidebarCmp) GetSize() (int, int) {
238− return m.width, m.height
239−}
240−
241−func NewSidebarCmp(session session.Session, history history.Service) util.Model {
242− return &sidebarCmp{
243− session: session,
244− history: history,
245− }
246−}
247−
248−func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
249− if m.history == nil || m.session.ID == "" {
250− return
251− }
252−
253− // Get all latest files for this session
254− latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
255− if err != nil {
256− return
257− }
258−
259− // Get all files for this session (to find initial versions)
260− allFiles, err := m.history.ListBySession(ctx, m.session.ID)
261− if err != nil {
262− return
263− }
264−
265− // Clear the existing map to rebuild it
266− m.modFiles = make(map[string]struct {
267− additions int
268− removals int
269− })
270−
271− // Process each latest file
272− for _, file := range latestFiles {
273− // Skip if this is the initial version (no changes to show)
274− if file.Version == history.InitialVersion {
275− continue
276− }
277−
278− // Find the initial version for this specific file
279− var initialVersion history.File
280− for _, v := range allFiles {
281− if v.Path == file.Path && v.Version == history.InitialVersion {
282− initialVersion = v
283− break
284− }
285− }
286−
287− // Skip if we can't find the initial version
288− if initialVersion.ID == "" {
289− continue
290− }
291− if initialVersion.Content == file.Content {
292− continue
293− }
294−
295− // Calculate diff between initial and latest version
296− _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
297−
298− // Only add to modified files if there are changes
299− if additions > 0 || removals > 0 {
300− // Remove working directory prefix from file path
301− displayPath := file.Path
302− workingDir := config.WorkingDirectory()
303− displayPath = strings.TrimPrefix(displayPath, workingDir)
304− displayPath = strings.TrimPrefix(displayPath, "/")
305−
306− m.modFiles[displayPath] = struct {
307− additions int
308− removals int
309− }{
310− additions: additions,
311− removals: removals,
312− }
313− }
314− }
315−}
316−
317−func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
318− // Skip if this is the initial version (no changes to show)
319− if file.Version == history.InitialVersion {
320− return
321− }
322−
323− // Find the initial version for this file
324− initialVersion, err := m.findInitialVersion(ctx, file.Path)
325− if err != nil || initialVersion.ID == "" {
326− return
327− }
328−
329− // Skip if content hasn't changed
330− if initialVersion.Content == file.Content {
331− // If this file was previously modified but now matches the initial version,
332− // remove it from the modified files list
333− displayPath := getDisplayPath(file.Path)
334− delete(m.modFiles, displayPath)
335− return
336− }
337−
338− // Calculate diff between initial and latest version
339− _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
340−
341− // Only add to modified files if there are changes
342− if additions > 0 || removals > 0 {
343− displayPath := getDisplayPath(file.Path)
344− m.modFiles[displayPath] = struct {
345− additions int
346− removals int
347− }{
348− additions: additions,
349− removals: removals,
350− }
351− } else {
352− // If no changes, remove from modified files
353− displayPath := getDisplayPath(file.Path)
354− delete(m.modFiles, displayPath)
355− }
356−}
357−
358−// Helper function to find the initial version of a file
359−func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
360− // Get all versions of this file for the session
361− fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
362− if err != nil {
363− return history.File{}, err
364− }
365−
366− // Find the initial version
367− for _, v := range fileVersions {
368− if v.Path == path && v.Version == history.InitialVersion {
369− return v, nil
370− }
371− }
372−
373− return history.File{}, fmt.Errorf("initial version not found")
374−}
375−
376−// Helper function to get the display path for a file
377−func getDisplayPath(path string) string {
378− workingDir := config.WorkingDirectory()
379− displayPath := strings.TrimPrefix(path, workingDir)
380− return strings.TrimPrefix(displayPath, "/")
381−}
internal/tui/components/core/status.godeleted−329
@@ -1,329 +0,0 @@
1−package core
2−
3−import (
4− "fmt"
5− "strings"
6− "time"
7−
8− tea "github.com/charmbracelet/bubbletea/v2"
9− "github.com/charmbracelet/lipgloss/v2"
10− "github.com/opencode-ai/opencode/internal/config"
11− "github.com/opencode-ai/opencode/internal/llm/models"
12− "github.com/opencode-ai/opencode/internal/logging"
13− "github.com/opencode-ai/opencode/internal/lsp"
14− "github.com/opencode-ai/opencode/internal/lsp/protocol"
15− "github.com/opencode-ai/opencode/internal/pubsub"
16− "github.com/opencode-ai/opencode/internal/session"
17− "github.com/opencode-ai/opencode/internal/tui/components/chat"
18− "github.com/opencode-ai/opencode/internal/tui/styles"
19− "github.com/opencode-ai/opencode/internal/tui/theme"
20− "github.com/opencode-ai/opencode/internal/tui/util"
21−)
22−
23−type StatusCmp interface {
24− util.Model
25−}
26−
27−type statusCmp struct {
28− info util.InfoMsg
29− width int
30− messageTTL time.Duration
31− lspClients map[string]*lsp.Client
32− session session.Session
33−}
34−
35−// clearMessageCmd is a command that clears status messages after a timeout
36−func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
37− return tea.Tick(ttl, func(time.Time) tea.Msg {
38− return util.ClearStatusMsg{}
39− })
40−}
41−
42−func (m statusCmp) Init() tea.Cmd {
43− return nil
44−}
45−
46−func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47− switch msg := msg.(type) {
48− case tea.WindowSizeMsg:
49− m.width = msg.Width
50− return m, nil
51−
52− // Handle sesson messages
53− case chat.SessionSelectedMsg:
54− m.session = msg
55− case chat.SessionClearedMsg:
56− m.session = session.Session{}
57− case pubsub.Event[session.Session]:
58− if msg.Type == pubsub.UpdatedEvent {
59− if m.session.ID == msg.Payload.ID {
60− m.session = msg.Payload
61− }
62− }
63−
64− // Handle status info
65− case util.InfoMsg:
66− m.info = msg
67− ttl := msg.TTL
68− if ttl == 0 {
69− ttl = m.messageTTL
70− }
71− return m, m.clearMessageCmd(ttl)
72− case util.ClearStatusMsg:
73− m.info = util.InfoMsg{}
74−
75− // Handle persistent logs
76− case pubsub.Event[logging.LogMessage]:
77− if msg.Payload.Persist {
78− switch msg.Payload.Level {
79− case "error":
80− m.info = util.InfoMsg{
81− Type: util.InfoTypeError,
82− Msg: msg.Payload.Message,
83− TTL: msg.Payload.PersistTime,
84− }
85− case "info":
86− m.info = util.InfoMsg{
87− Type: util.InfoTypeInfo,
88− Msg: msg.Payload.Message,
89− TTL: msg.Payload.PersistTime,
90− }
91− case "warn":
92− m.info = util.InfoMsg{
93− Type: util.InfoTypeWarn,
94− Msg: msg.Payload.Message,
95− TTL: msg.Payload.PersistTime,
96− }
97− default:
98− m.info = util.InfoMsg{
99− Type: util.InfoTypeInfo,
100− Msg: msg.Payload.Message,
101− TTL: msg.Payload.PersistTime,
102− }
103− }
104− }
105− }
106− return m, nil
107−}
108−
109−var helpWidget = ""
110−
111−// getHelpWidget returns the help widget with current theme colors
112−func getHelpWidget() string {
113− t := theme.CurrentTheme()
114− helpText := "ctrl+? help"
115−
116− return styles.Padded().
117− Background(t.TextMuted()).
118− Foreground(t.BackgroundDarker()).
119− Bold(true).
120− Render(helpText)
121−}
122−
123−func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
124− // Format tokens in human-readable format (e.g., 110K, 1.2M)
125− var formattedTokens string
126− switch {
127− case tokens >= 1_000_000:
128− formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
129− case tokens >= 1_000:
130− formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
131− default:
132− formattedTokens = fmt.Sprintf("%d", tokens)
133− }
134−
135− // Remove .0 suffix if present
136− if strings.HasSuffix(formattedTokens, ".0K") {
137− formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
138− }
139− if strings.HasSuffix(formattedTokens, ".0M") {
140− formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
141− }
142−
143− // Format cost with $ symbol and 2 decimal places
144− formattedCost := fmt.Sprintf("$%.2f", cost)
145−
146− percentage := (float64(tokens) / float64(contextWindow)) * 100
147− if percentage > 80 {
148− // add the warning icon and percentage
149− formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
150− }
151−
152− return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
153−}
154−
155−func (m statusCmp) View() tea.View {
156− t := theme.CurrentTheme()
157− modelID := config.Get().Agents[config.AgentCoder].Model
158− model := models.SupportedModels[modelID]
159−
160− // Initialize the help widget
161− status := getHelpWidget()
162−
163− tokenInfoWidth := 0
164− if m.session.ID != "" {
165− totalTokens := m.session.PromptTokens + m.session.CompletionTokens
166− tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
167− tokensStyle := styles.Padded().
168− Background(t.Text()).
169− Foreground(t.BackgroundSecondary())
170− percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
171− if percentage > 80 {
172− tokensStyle = tokensStyle.Background(t.Warning())
173− }
174− tokenInfoWidth = lipgloss.Width(tokens) + 2
175− status += tokensStyle.Render(tokens)
176− }
177−
178− diagnostics := styles.Padded().
179− Background(t.BackgroundDarker()).
180− Render(m.projectDiagnostics())
181−
182− availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
183−
184− if m.info.Msg != "" {
185− infoStyle := styles.Padded().
186− Foreground(t.Background()).
187− Width(availableWidht)
188−
189− switch m.info.Type {
190− case util.InfoTypeInfo:
191− infoStyle = infoStyle.Background(t.Info())
192− case util.InfoTypeWarn:
193− infoStyle = infoStyle.Background(t.Warning())
194− case util.InfoTypeError:
195− infoStyle = infoStyle.Background(t.Error())
196− }
197−
198− infoWidth := availableWidht - 10
199− // Truncate message if it's longer than available width
200− msg := m.info.Msg
201− if len(msg) > infoWidth && infoWidth > 0 {
202− msg = msg[:infoWidth] + "..."
203− }
204− status += infoStyle.Render(msg)
205− } else {
206− status += styles.Padded().
207− Foreground(t.Text()).
208− Background(t.BackgroundSecondary()).
209− Width(availableWidht).
210− Render("")
211− }
212−
213− status += diagnostics
214− status += m.model()
215− return tea.NewView(status)
216−}
217−
218−func (m *statusCmp) projectDiagnostics() string {
219− t := theme.CurrentTheme()
220−
221− // Check if any LSP server is still initializing
222− initializing := false
223− for _, client := range m.lspClients {
224− if client.GetServerState() == lsp.StateStarting {
225− initializing = true
226− break
227− }
228− }
229−
230− // If any server is initializing, show that status
231− if initializing {
232− return lipgloss.NewStyle().
233− Background(t.BackgroundDarker()).
234− Foreground(t.Warning()).
235− Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
236− }
237−
238− errorDiagnostics := []protocol.Diagnostic{}
239− warnDiagnostics := []protocol.Diagnostic{}
240− hintDiagnostics := []protocol.Diagnostic{}
241− infoDiagnostics := []protocol.Diagnostic{}
242− for _, client := range m.lspClients {
243− for _, d := range client.GetDiagnostics() {
244− for _, diag := range d {
245− switch diag.Severity {
246− case protocol.SeverityError:
247− errorDiagnostics = append(errorDiagnostics, diag)
248− case protocol.SeverityWarning:
249− warnDiagnostics = append(warnDiagnostics, diag)
250− case protocol.SeverityHint:
251− hintDiagnostics = append(hintDiagnostics, diag)
252− case protocol.SeverityInformation:
253− infoDiagnostics = append(infoDiagnostics, diag)
254− }
255− }
256− }
257− }
258−
259− if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
260− return "No diagnostics"
261− }
262−
263− diagnostics := []string{}
264−
265− if len(errorDiagnostics) > 0 {
266− errStr := lipgloss.NewStyle().
267− Background(t.BackgroundDarker()).
268− Foreground(t.Error()).
269− Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
270− diagnostics = append(diagnostics, errStr)
271− }
272− if len(warnDiagnostics) > 0 {
273− warnStr := lipgloss.NewStyle().
274− Background(t.BackgroundDarker()).
275− Foreground(t.Warning()).
276− Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
277− diagnostics = append(diagnostics, warnStr)
278− }
279− if len(hintDiagnostics) > 0 {
280− hintStr := lipgloss.NewStyle().
281− Background(t.BackgroundDarker()).
282− Foreground(t.Text()).
283− Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
284− diagnostics = append(diagnostics, hintStr)
285− }
286− if len(infoDiagnostics) > 0 {
287− infoStr := lipgloss.NewStyle().
288− Background(t.BackgroundDarker()).
289− Foreground(t.Info()).
290− Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
291− diagnostics = append(diagnostics, infoStr)
292− }
293−
294− return strings.Join(diagnostics, " ")
295−}
296−
297−func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
298− tokensWidth := 0
299− if m.session.ID != "" {
300− tokensWidth = lipgloss.Width(tokenInfo) + 2
301− }
302− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
303−}
304−
305−func (m statusCmp) model() string {
306− t := theme.CurrentTheme()
307−
308− cfg := config.Get()
309−
310− coder, ok := cfg.Agents[config.AgentCoder]
311− if !ok {
312− return "Unknown"
313− }
314− model := models.SupportedModels[coder.Model]
315−
316− return styles.Padded().
317− Background(t.Secondary()).
318− Foreground(t.Background()).
319− Render(model.Name)
320−}
321−
322−func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
323− helpWidget = getHelpWidget()
324−
325− return &statusCmp{
326− messageTTL: 10 * time.Second,
327− lspClients: lspClients,
328− }
329−}
internal/tui/components/util/simple-list.godeleted−164
@@ -1,164 +0,0 @@
1−package utilComponents
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/lipgloss/v2"
7− "github.com/opencode-ai/opencode/internal/tui/layout"
8− "github.com/opencode-ai/opencode/internal/tui/styles"
9− "github.com/opencode-ai/opencode/internal/tui/theme"
10− "github.com/opencode-ai/opencode/internal/tui/util"
11−)
12−
13−type SimpleListItem interface {
14− Render(selected bool, width int) string
15−}
16−
17−type SimpleList[T SimpleListItem] interface {
18− util.Model
19− layout.Bindings
20− SetMaxWidth(maxWidth int)
21− GetSelectedItem() (item T, idx int)
22− SetItems(items []T)
23− GetItems() []T
24−}
25−
26−type simpleListCmp[T SimpleListItem] struct {
27− fallbackMsg string
28− items []T
29− selectedIdx int
30− maxWidth int
31− maxVisibleItems int
32− useAlphaNumericKeys bool
33− width int
34− height int
35−}
36−
37−type simpleListKeyMap struct {
38− Up key.Binding
39− Down key.Binding
40− UpAlpha key.Binding
41− DownAlpha key.Binding
42−}
43−
44−var simpleListKeys = simpleListKeyMap{
45− Up: key.NewBinding(
46− key.WithKeys("up"),
47− key.WithHelp("↑", "previous list item"),
48− ),
49− Down: key.NewBinding(
50− key.WithKeys("down"),
51− key.WithHelp("↓", "next list item"),
52− ),
53− UpAlpha: key.NewBinding(
54− key.WithKeys("k"),
55− key.WithHelp("k", "previous list item"),
56− ),
57− DownAlpha: key.NewBinding(
58− key.WithKeys("j"),
59− key.WithHelp("j", "next list item"),
60− ),
61−}
62−
63−func (c *simpleListCmp[T]) Init() tea.Cmd {
64− return nil
65−}
66−
67−func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68− switch msg := msg.(type) {
69− case tea.KeyPressMsg:
70− switch {
71− case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
72− if c.selectedIdx > 0 {
73− c.selectedIdx--
74− }
75− return c, nil
76− case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
77− if c.selectedIdx < len(c.items)-1 {
78− c.selectedIdx++
79− }
80− return c, nil
81− }
82− }
83−
84− return c, nil
85−}
86−
87−func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
88− return layout.KeyMapToSlice(simpleListKeys)
89−}
90−
91−func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
92− if len(c.items) > 0 {
93− return c.items[c.selectedIdx], c.selectedIdx
94− }
95−
96− var zero T
97− return zero, -1
98−}
99−
100−func (c *simpleListCmp[T]) SetItems(items []T) {
101− c.selectedIdx = 0
102− c.items = items
103−}
104−
105−func (c *simpleListCmp[T]) GetItems() []T {
106− return c.items
107−}
108−
109−func (c *simpleListCmp[T]) SetMaxWidth(width int) {
110− c.maxWidth = width
111−}
112−
113−func (c *simpleListCmp[T]) View() tea.View {
114− t := theme.CurrentTheme()
115− baseStyle := styles.BaseStyle()
116−
117− items := c.items
118− maxWidth := c.maxWidth
119− maxVisibleItems := min(c.maxVisibleItems, len(items))
120− startIdx := 0
121−
122− if len(items) <= 0 {
123− return tea.NewView(
124− baseStyle.
125− Background(t.Background()).
126− Padding(0, 1).
127− Width(maxWidth).
128− Render(c.fallbackMsg),
129− )
130− }
131−
132− if len(items) > maxVisibleItems {
133− halfVisible := maxVisibleItems / 2
134− if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
135− startIdx = c.selectedIdx - halfVisible
136− } else if c.selectedIdx >= len(items)-halfVisible {
137− startIdx = len(items) - maxVisibleItems
138− }
139− }
140−
141− endIdx := min(startIdx+maxVisibleItems, len(items))
142−
143− listItems := make([]string, 0, maxVisibleItems)
144−
145− for i := startIdx; i < endIdx; i++ {
146− item := items[i]
147− title := item.Render(i == c.selectedIdx, maxWidth)
148− listItems = append(listItems, title)
149− }
150−
151− return tea.NewView(
152− lipgloss.JoinVertical(lipgloss.Left, listItems...),
153− )
154−}
155−
156−func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
157− return &simpleListCmp[T]{
158− fallbackMsg: fallbackMsg,
159− items: items,
160− maxVisibleItems: maxVisibleItems,
161− useAlphaNumericKeys: useAlphaNumericKeys,
162− selectedIdx: 0,
163− }
164−}
internal/tui/page/chat.godeleted−176
@@ -1,176 +0,0 @@
1−package page
2−
3−import (
4− "context"
5−
6− "github.com/charmbracelet/bubbles/v2/key"
7− tea "github.com/charmbracelet/bubbletea/v2"
8− "github.com/opencode-ai/opencode/internal/app"
9− "github.com/opencode-ai/opencode/internal/logging"
10− "github.com/opencode-ai/opencode/internal/message"
11− "github.com/opencode-ai/opencode/internal/session"
12− "github.com/opencode-ai/opencode/internal/tui/components/chat"
13− "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
14− "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
15− "github.com/opencode-ai/opencode/internal/tui/layout"
16− "github.com/opencode-ai/opencode/internal/tui/util"
17−)
18−
19−var ChatPage PageID = "chat"
20−
21−type chatPage struct {
22− app *app.App
23− editor layout.Container
24− messages layout.Container
25− layout layout.SplitPaneLayout
26− session session.Session
27−}
28−
29−type ChatKeyMap struct {
30− NewSession key.Binding
31− Cancel key.Binding
32−}
33−
34−var keyMap = ChatKeyMap{
35− NewSession: key.NewBinding(
36− key.WithKeys("ctrl+n"),
37− key.WithHelp("ctrl+n", "new session"),
38− ),
39− Cancel: key.NewBinding(
40− key.WithKeys("esc"),
41− key.WithHelp("esc", "cancel"),
42− ),
43−}
44−
45−func (p *chatPage) Init() tea.Cmd {
46− return p.layout.Init()
47−}
48−
49−func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50− var cmds []tea.Cmd
51− switch msg := msg.(type) {
52− case tea.WindowSizeMsg:
53− logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
54− cmd := p.layout.SetSize(msg.Width, msg.Height)
55− cmds = append(cmds, cmd)
56− case chat.SendMsg:
57− cmd := p.sendMessage(msg.Text, msg.Attachments)
58− if cmd != nil {
59− return p, cmd
60− }
61− case commands.CommandRunCustomMsg:
62− // Check if the agent is busy before executing custom commands
63− if p.app.CoderAgent.IsBusy() {
64− return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
65− }
66−
67− // Handle custom command execution
68− cmd := p.sendMessage(msg.Content, nil)
69− if cmd != nil {
70− return p, cmd
71− }
72− case chat.SessionSelectedMsg:
73− if p.session.ID == "" {
74− cmd := p.setSidebar()
75− if cmd != nil {
76− cmds = append(cmds, cmd)
77− }
78− }
79− p.session = msg
80− case tea.KeyPressMsg:
81− switch {
82− case key.Matches(msg, keyMap.NewSession):
83− p.session = session.Session{}
84− return p, tea.Batch(
85− p.clearSidebar(),
86− util.CmdHandler(chat.SessionClearedMsg{}),
87− )
88− case key.Matches(msg, keyMap.Cancel):
89− if p.session.ID != "" {
90− // Cancel the current session's generation process
91− // This allows users to interrupt long-running operations
92− p.app.CoderAgent.Cancel(p.session.ID)
93− return p, nil
94− }
95− }
96− }
97− u, cmd := p.layout.Update(msg)
98− cmds = append(cmds, cmd)
99− p.layout = u.(layout.SplitPaneLayout)
100−
101− return p, tea.Batch(cmds...)
102−}
103−
104−func (p *chatPage) setSidebar() tea.Cmd {
105− sidebarContainer := layout.NewContainer(
106− chat.NewSidebarCmp(p.session, p.app.History),
107− layout.WithPadding(1, 1, 1, 1),
108− )
109− return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
110−}
111−
112−func (p *chatPage) clearSidebar() tea.Cmd {
113− return p.layout.ClearRightPanel()
114−}
115−
116−func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
117− var cmds []tea.Cmd
118− if p.session.ID == "" {
119− session, err := p.app.Sessions.Create(context.Background(), "New Session")
120− if err != nil {
121− return util.ReportError(err)
122− }
123−
124− p.session = session
125− cmd := p.setSidebar()
126− if cmd != nil {
127− cmds = append(cmds, cmd)
128− }
129− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
130− }
131−
132− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
133− if err != nil {
134− return util.ReportError(err)
135− }
136− return tea.Batch(cmds...)
137−}
138−
139−func (p *chatPage) SetSize(width, height int) tea.Cmd {
140− return p.layout.SetSize(width, height)
141−}
142−
143−func (p *chatPage) GetSize() (int, int) {
144− return p.layout.GetSize()
145−}
146−
147−func (p *chatPage) View() tea.View {
148− return p.layout.View()
149−}
150−
151−func (p *chatPage) BindingKeys() []key.Binding {
152− bindings := layout.KeyMapToSlice(keyMap)
153− bindings = append(bindings, p.messages.BindingKeys()...)
154− bindings = append(bindings, p.editor.BindingKeys()...)
155− return bindings
156−}
157−
158−func NewChatPage(app *app.App) util.Model {
159− messagesContainer := layout.NewContainer(
160− chat.NewMessagesListCmp(app),
161− layout.WithPadding(1, 1, 0, 1),
162− )
163− editorContainer := layout.NewContainer(
164− editor.NewEditorCmp(app),
165− layout.WithBorder(true, false, false, false),
166− )
167− return &chatPage{
168− app: app,
169− editor: editorContainer,
170− messages: messagesContainer,
171− layout: layout.NewSplitPane(
172− layout.WithLeftPanel(messagesContainer),
173− layout.WithBottomPanel(editorContainer),
174− ),
175− }
176−}
internal/tui/styles/chroma.gonew file+79
@@ -0,0 +1,79 @@
1+package styles
2+
3+import (
4+ "github.com/alecthomas/chroma/v2"
5+ "github.com/charmbracelet/glamour/v2/ansi"
6+)
7+
8+func chromaStyle(style ansi.StylePrimitive) string {
9+ var s string
10+
11+ if style.Color != nil {
12+ s = *style.Color
13+ }
14+ if style.BackgroundColor != nil {
15+ if s != "" {
16+ s += " "
17+ }
18+ s += "bg:" + *style.BackgroundColor
19+ }
20+ if style.Italic != nil && *style.Italic {
21+ if s != "" {
22+ s += " "
23+ }
24+ s += "italic"
25+ }
26+ if style.Bold != nil && *style.Bold {
27+ if s != "" {
28+ s += " "
29+ }
30+ s += "bold"
31+ }
32+ if style.Underline != nil && *style.Underline {
33+ if s != "" {
34+ s += " "
35+ }
36+ s += "underline"
37+ }
38+
39+ return s
40+}
41+
42+func GetChromaTheme() chroma.StyleEntries {
43+ t := CurrentTheme()
44+ rules := t.S().Markdown.CodeBlock
45+
46+ return chroma.StyleEntries{
47+ chroma.Text: chromaStyle(rules.Chroma.Text),
48+ chroma.Error: chromaStyle(rules.Chroma.Error),
49+ chroma.Comment: chromaStyle(rules.Chroma.Comment),
50+ chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc),
51+ chroma.Keyword: chromaStyle(rules.Chroma.Keyword),
52+ chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved),
53+ chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace),
54+ chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType),
55+ chroma.Operator: chromaStyle(rules.Chroma.Operator),
56+ chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation),
57+ chroma.Name: chromaStyle(rules.Chroma.Name),
58+ chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin),
59+ chroma.NameTag: chromaStyle(rules.Chroma.NameTag),
60+ chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute),
61+ chroma.NameClass: chromaStyle(rules.Chroma.NameClass),
62+ chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant),
63+ chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator),
64+ chroma.NameException: chromaStyle(rules.Chroma.NameException),
65+ chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction),
66+ chroma.NameOther: chromaStyle(rules.Chroma.NameOther),
67+ chroma.Literal: chromaStyle(rules.Chroma.Literal),
68+ chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber),
69+ chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate),
70+ chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString),
71+ chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
72+ chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted),
73+ chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph),
74+ chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted),
75+ chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong),
76+ chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading),
77+ chroma.Background: chromaStyle(rules.Chroma.Background),
78+ }
79+}
internal/tui/styles/crush.go+10−10
@@ -1,7 +1,6 @@
1package styles
1package styles
2
2
3import (
3import (
4− "github.com/charmbracelet/lipgloss/v2"
5 "github.com/charmbracelet/x/exp/charmtone"
4 "github.com/charmbracelet/x/exp/charmtone"
6)
5)
7
6
@@ -14,10 +13,6 @@ func NewCrushTheme() *Theme {
14 Secondary: charmtone.Dolly,
13 Secondary: charmtone.Dolly,
15 Tertiary: charmtone.Bok,
14 Tertiary: charmtone.Bok,
16 Accent: charmtone.Zest,
15 Accent: charmtone.Zest,
17−
18− Blue: lipgloss.Color(charmtone.Malibu.Hex()),
19− PrimaryLight: charmtone.Hazy,
20−
21 // Backgrounds
16 // Backgrounds
22 BgBase: charmtone.Pepper,
17 BgBase: charmtone.Pepper,
23 BgSubtle: charmtone.Charcoal,
18 BgSubtle: charmtone.Charcoal,
@@ -40,10 +35,15 @@ func NewCrushTheme() *Theme {
40 Warning: charmtone.Uni,
35 Warning: charmtone.Uni,
41 Info: charmtone.Malibu,
36 Info: charmtone.Malibu,
42
37
43− // TODO: fix this.
38+ // Colors
44− SyntaxBg: lipgloss.Color("#1C1C1F"),
39+ Blue: charmtone.Malibu,
45− SyntaxKeyword: lipgloss.Color("#FF6DFE"),
40+
46− SyntaxString: lipgloss.Color("#E8FE96"),
41+ Green: charmtone.Julep,
47− SyntaxComment: lipgloss.Color("#6B6F85"),
42+ GreenDark: charmtone.Guac,
43+ GreenLight: charmtone.Bok,
44+
45+ Red: charmtone.Coral,
46+ RedDark: charmtone.Sriracha,
47+ RedLight: charmtone.Salmon,
48 }
48 }
49}
49}
internal/tui/styles/theme.go+49−9
@@ -30,10 +30,6 @@ type Theme struct {
30 Tertiary color.Color
30 Tertiary color.Color
31 Accent color.Color
31 Accent color.Color
32
32
33− // Colors
34− Blue color.Color
35− // TODO: add any others needed
36−
37 BgBase color.Color
33 BgBase color.Color
38 BgSubtle color.Color
34 BgSubtle color.Color
39 BgOverlay color.Color
35 BgOverlay color.Color
@@ -52,15 +48,40 @@ type Theme struct {
52 Warning color.Color
48 Warning color.Color
53 Info color.Color
49 Info color.Color
54
50
55− // TODO: add more syntax colors, maybe just use a chroma theme here.
51+ // Colors
56− SyntaxBg color.Color
52+ // Blues
57− SyntaxKeyword color.Color
53+ Blue color.Color
58− SyntaxString color.Color
54+
59− SyntaxComment color.Color
55+ // Greens
56+ Green color.Color
57+ GreenDark color.Color
58+ GreenLight color.Color
59+
60+ // Reds
61+ Red color.Color
62+ RedDark color.Color
63+ RedLight color.Color
64+
65+ // TODO: add any others needed
60
66
61 styles *Styles
67 styles *Styles
62}
68}
63
69
70+type Diff struct {
71+ Added color.Color
72+ Removed color.Color
73+ Context color.Color
74+ HunkHeader color.Color
75+ HighlightAdded color.Color
76+ HighlightRemoved color.Color
77+ AddedBg color.Color
78+ RemovedBg color.Color
79+ ContextBg color.Color
80+ LineNumber color.Color
81+ AddedLineNumberBg color.Color
82+ RemovedLineNumberBg color.Color
83+}
84+
64type Styles struct {
85type Styles struct {
65 Base lipgloss.Style
86 Base lipgloss.Style
66 SelectedBase lipgloss.Style
87 SelectedBase lipgloss.Style
@@ -86,6 +107,9 @@ type Styles struct {
86
107
87 // Help
108 // Help
88 Help help.Styles
109 Help help.Styles
110+
111+ // Diff
112+ Diff Diff
89}
113}
90
114
91func (t *Theme) S() *Styles {
115func (t *Theme) S() *Styles {
@@ -390,6 +414,22 @@ func (t *Theme) buildStyles() *Styles {
390 FullDesc: base.Foreground(t.FgSubtle),
414 FullDesc: base.Foreground(t.FgSubtle),
391 FullSeparator: base.Foreground(t.Border),
415 FullSeparator: base.Foreground(t.Border),
392 },
416 },
417+
418+ // TODO: Fix this this is bad
419+ Diff: Diff{
420+ Added: t.Green,
421+ Removed: t.Red,
422+ Context: t.FgSubtle,
423+ HunkHeader: t.FgSubtle,
424+ HighlightAdded: t.GreenLight,
425+ HighlightRemoved: t.RedLight,
426+ AddedBg: t.GreenDark,
427+ RemovedBg: t.RedDark,
428+ ContextBg: t.BgSubtle,
429+ LineNumber: t.FgMuted,
430+ AddedLineNumberBg: t.GreenDark,
431+ RemovedLineNumberBg: t.RedDark,
432+ },
393 }
433 }
394}
434}
395
435
internal/tui/tui.go+6−7
@@ -11,7 +11,6 @@ import (
11 "github.com/opencode-ai/opencode/internal/pubsub"
11 "github.com/opencode-ai/opencode/internal/pubsub"
12 cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
12 cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
13 "github.com/opencode-ai/opencode/internal/tui/components/completions"
13 "github.com/opencode-ai/opencode/internal/tui/components/completions"
14− "github.com/opencode-ai/opencode/internal/tui/components/core"
15 "github.com/opencode-ai/opencode/internal/tui/components/core/status"
14 "github.com/opencode-ai/opencode/internal/tui/components/core/status"
16 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
15 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
17 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
16 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
@@ -95,7 +94,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95 // Status Messages
94 // Status Messages
96 case util.InfoMsg, util.ClearStatusMsg:
95 case util.InfoMsg, util.ClearStatusMsg:
97 s, statusCmd := a.status.Update(msg)
96 s, statusCmd := a.status.Update(msg)
98− a.status = s.(core.StatusCmp)
97+ a.status = s.(status.StatusCmp)
99 cmds = append(cmds, statusCmd)
98 cmds = append(cmds, statusCmd)
100 return a, tea.Batch(cmds...)
99 return a, tea.Batch(cmds...)
101
100
@@ -108,7 +107,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108 case pubsub.Event[logging.LogMessage]:
107 case pubsub.Event[logging.LogMessage]:
109 // Send to the status component
108 // Send to the status component
110 s, statusCmd := a.status.Update(msg)
109 s, statusCmd := a.status.Update(msg)
111− a.status = s.(core.StatusCmp)
110+ a.status = s.(status.StatusCmp)
112 cmds = append(cmds, statusCmd)
111 cmds = append(cmds, statusCmd)
113
112
114 // If the current page is logs, update the logs view
113 // If the current page is logs, update the logs view
@@ -136,7 +135,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
136 return a, a.handleKeyPressMsg(msg)
135 return a, a.handleKeyPressMsg(msg)
137 }
136 }
138 s, _ := a.status.Update(msg)
137 s, _ := a.status.Update(msg)
139− a.status = s.(core.StatusCmp)
138+ a.status = s.(status.StatusCmp)
140 updated, cmd := a.pages[a.currentPage].Update(msg)
139 updated, cmd := a.pages[a.currentPage].Update(msg)
141 a.pages[a.currentPage] = updated.(util.Model)
140 a.pages[a.currentPage] = updated.(util.Model)
142 cmds = append(cmds, cmd)
141 cmds = append(cmds, cmd)
@@ -151,7 +150,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
151
150
152 // Update status bar
151 // Update status bar
153 s, cmd := a.status.Update(msg)
152 s, cmd := a.status.Update(msg)
154− a.status = s.(core.StatusCmp)
153+ a.status = s.(status.StatusCmp)
155 cmds = append(cmds, cmd)
154 cmds = append(cmds, cmd)
156
155
157 // Update the current page
156 // Update the current page
@@ -285,7 +284,7 @@ func (a *appModel) View() tea.View {
285
284
286// New creates and initializes a new TUI application model.
285// New creates and initializes a new TUI application model.
287func New(app *app.App) tea.Model {
286func New(app *app.App) tea.Model {
288− startPage := page.ChatPage
287+ startPage := chat.ChatPage
289 model := &appModel{
288 model := &appModel{
290 currentPage: startPage,
289 currentPage: startPage,
291 app: app,
290 app: app,
@@ -294,7 +293,7 @@ func New(app *app.App) tea.Model {
294 keyMap: DefaultKeyMap(),
293 keyMap: DefaultKeyMap(),
295
294
296 pages: map[page.PageID]util.Model{
295 pages: map[page.PageID]util.Model{
297− page.ChatPage: chat.NewChatPage(app),
296+ chat.ChatPage: chat.NewChatPage(app),
298 page.LogsPage: page.NewLogsPage(),
297 page.LogsPage: page.NewLogsPage(),
299 },
298 },
300
299
todos.md+6
@@ -13,8 +13,14 @@
13- [x] Sessions dialog
13- [x] Sessions dialog
14- [ ] Models
14- [ ] Models
15- [~] Move sessions and model dialog to the commands
15- [~] Move sessions and model dialog to the commands
16+- [ ] Add sessions shortuct
17+- [ ] Add all posible actions to the commands
16
18
17## Investigate
19## Investigate
18
20
19- [ ] Events when tool error
21- [ ] Events when tool error
20- [ ] Fancy Spinner
22- [ ] Fancy Spinner
23+
24+## Messages
25+
26+- [ ] Fix issue with numbers (padding)